swytchcode-runtime 1.1.5__tar.gz → 1.2.0__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.
- {swytchcode_runtime-1.1.5/swytchcode_runtime.egg-info → swytchcode_runtime-1.2.0}/PKG-INFO +21 -1
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/README.md +20 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/pyproject.toml +1 -1
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/__init__.py +5 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/cli.py +5 -2
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/client.py +33 -2
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/exec.py +22 -0
- swytchcode_runtime-1.2.0/swytchcode_runtime/tenants.py +143 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0/swytchcode_runtime.egg-info}/PKG-INFO +21 -1
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime.egg-info/SOURCES.txt +3 -1
- swytchcode_runtime-1.2.0/tests/test_tenants.py +175 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/LICENSE.md +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/setup.cfg +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/discover.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/errors.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/manage.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/prompts.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/__init__.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/anthropic.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/base.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/crewai.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/langgraph.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/openai_agents.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/vercel.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/schema.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime.egg-info/dependency_links.txt +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime.egg-info/top_level.txt +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_alias.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_exec_errors.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_nested_body.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_prompts.py +0 -0
- {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_schema_routing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swytchcode-runtime
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Thin runtime wrapper around the Swytchcode CLI
|
|
5
5
|
Author: Swytchcode
|
|
6
6
|
License: MIT
|
|
@@ -86,6 +86,8 @@ output = exec("api.report.export", {"id": "123"}, raw=True)
|
|
|
86
86
|
- **raw** - If `True`, use `--raw` and return stdout as a string.
|
|
87
87
|
- **dry_run** - If `True`, pass `--dry-run` to the CLI; request details (method, url, headers, body) are output instead of calling the server.
|
|
88
88
|
- **allow_raw** - If `True`, pass `--allow-raw` to the CLI; required for executing raw methods (kernel has this disabled by default).
|
|
89
|
+
- **tenant_id** - Run the call for one end user of your app, with their own connected account (passes `--tenant`). Never falls back to your account. See [Multi-tenant apps](#multi-tenant-apps).
|
|
90
|
+
- **tenant_label** - How approvers see that end user, e.g. `"Alice Smith (alice@acme.com)"` (passes `--tenant-label`). Needs `tenant_id`.
|
|
89
91
|
|
|
90
92
|
This runtime invokes `swytchcode exec [canonical_id]` with the flags above. For full exec behavior, see the Swytchcode kernel documentation.
|
|
91
93
|
|
|
@@ -121,6 +123,24 @@ except SwytchcodeError as e:
|
|
|
121
123
|
print(e.details.get("suggested_action"))
|
|
122
124
|
```
|
|
123
125
|
|
|
126
|
+
## Multi-tenant apps
|
|
127
|
+
|
|
128
|
+
When your app's own users each connect their accounts, pass your id for the logged-in user (from your server's session, never from the browser) on every call made for them:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from swytchcode_runtime import connect, save_key, save_credentials, disconnect, exec, Swytchcode
|
|
132
|
+
|
|
133
|
+
url = connect("gmail", user.id)["url"] # one-time link (OAuth), open it from the user's click
|
|
134
|
+
save_key("stripe", user.id, key) # API-key providers: stored on your server only
|
|
135
|
+
save_credentials("coinswitch", user.id, # signed requests (crypto exchanges): every field
|
|
136
|
+
{"api_key": api_key, "secret_key": secret_key}) # the exchange needs, stored on your server only
|
|
137
|
+
exec("gmail.user.profile.get", {"params": {"userId": "me"}}, tenant_id=user.id)
|
|
138
|
+
client = Swytchcode(provider, tenant_id=user.id) # agents: every tool runs for this user
|
|
139
|
+
disconnect("gmail", user.id)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
An end user who has not connected raises `SwytchcodeError` with `details["category"] == "tenant_not_connected"`. For exchanges that sign every request, `save_credentials()` takes the fields the provider's signing recipe names (for example `api_key`, `secret_key` and `passphrase`); the CLI signs each of that user's requests with them. Guide: https://docs.swytchcode.com/guides/multi-tenant/
|
|
143
|
+
|
|
124
144
|
## What this library is
|
|
125
145
|
|
|
126
146
|
- A thin process wrapper over the Swytchcode shell.
|
|
@@ -68,6 +68,8 @@ output = exec("api.report.export", {"id": "123"}, raw=True)
|
|
|
68
68
|
- **raw** - If `True`, use `--raw` and return stdout as a string.
|
|
69
69
|
- **dry_run** - If `True`, pass `--dry-run` to the CLI; request details (method, url, headers, body) are output instead of calling the server.
|
|
70
70
|
- **allow_raw** - If `True`, pass `--allow-raw` to the CLI; required for executing raw methods (kernel has this disabled by default).
|
|
71
|
+
- **tenant_id** - Run the call for one end user of your app, with their own connected account (passes `--tenant`). Never falls back to your account. See [Multi-tenant apps](#multi-tenant-apps).
|
|
72
|
+
- **tenant_label** - How approvers see that end user, e.g. `"Alice Smith (alice@acme.com)"` (passes `--tenant-label`). Needs `tenant_id`.
|
|
71
73
|
|
|
72
74
|
This runtime invokes `swytchcode exec [canonical_id]` with the flags above. For full exec behavior, see the Swytchcode kernel documentation.
|
|
73
75
|
|
|
@@ -103,6 +105,24 @@ except SwytchcodeError as e:
|
|
|
103
105
|
print(e.details.get("suggested_action"))
|
|
104
106
|
```
|
|
105
107
|
|
|
108
|
+
## Multi-tenant apps
|
|
109
|
+
|
|
110
|
+
When your app's own users each connect their accounts, pass your id for the logged-in user (from your server's session, never from the browser) on every call made for them:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from swytchcode_runtime import connect, save_key, save_credentials, disconnect, exec, Swytchcode
|
|
114
|
+
|
|
115
|
+
url = connect("gmail", user.id)["url"] # one-time link (OAuth), open it from the user's click
|
|
116
|
+
save_key("stripe", user.id, key) # API-key providers: stored on your server only
|
|
117
|
+
save_credentials("coinswitch", user.id, # signed requests (crypto exchanges): every field
|
|
118
|
+
{"api_key": api_key, "secret_key": secret_key}) # the exchange needs, stored on your server only
|
|
119
|
+
exec("gmail.user.profile.get", {"params": {"userId": "me"}}, tenant_id=user.id)
|
|
120
|
+
client = Swytchcode(provider, tenant_id=user.id) # agents: every tool runs for this user
|
|
121
|
+
disconnect("gmail", user.id)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
An end user who has not connected raises `SwytchcodeError` with `details["category"] == "tenant_not_connected"`. For exchanges that sign every request, `save_credentials()` takes the fields the provider's signing recipe names (for example `api_key`, `secret_key` and `passphrase`); the CLI signs each of that user's requests with them. Guide: https://docs.swytchcode.com/guides/multi-tenant/
|
|
125
|
+
|
|
106
126
|
## What this library is
|
|
107
127
|
|
|
108
128
|
- A thin process wrapper over the Swytchcode shell.
|
|
@@ -4,13 +4,18 @@ from .client import Swytchcode
|
|
|
4
4
|
from .errors import SwytchcodeError, is_swytchcode_error
|
|
5
5
|
from .exec import exec_ as exec
|
|
6
6
|
from .prompts import TOOL_USE_INSTRUCTIONS
|
|
7
|
+
from .tenants import connect, disconnect, save_credentials, save_key
|
|
7
8
|
|
|
8
9
|
__all__ = [
|
|
9
10
|
"TOOL_USE_INSTRUCTIONS",
|
|
10
11
|
"Swytchcode",
|
|
11
12
|
"SwytchcodeError",
|
|
13
|
+
"connect",
|
|
14
|
+
"disconnect",
|
|
12
15
|
"exec",
|
|
13
16
|
"is_swytchcode_error",
|
|
17
|
+
"save_credentials",
|
|
18
|
+
"save_key",
|
|
14
19
|
]
|
|
15
20
|
|
|
16
21
|
|
|
@@ -17,18 +17,21 @@ def run_cli(
|
|
|
17
17
|
cwd: str | None = None,
|
|
18
18
|
env: dict | None = None,
|
|
19
19
|
timeout: float | None = 60,
|
|
20
|
+
input: str | None = None,
|
|
21
|
+
json_output: bool = True,
|
|
20
22
|
) -> Any:
|
|
21
23
|
run_env = os.environ.copy()
|
|
22
24
|
if env:
|
|
23
25
|
run_env.update(env)
|
|
24
26
|
|
|
25
27
|
cmd = [_resolve_bin(run_env), *args]
|
|
26
|
-
if "--json" not in args:
|
|
28
|
+
if json_output and "--json" not in args:
|
|
27
29
|
cmd.append("--json")
|
|
28
30
|
|
|
29
31
|
try:
|
|
30
32
|
r = subprocess.run(
|
|
31
33
|
cmd,
|
|
34
|
+
input=input.encode("utf-8") if input is not None else None,
|
|
32
35
|
capture_output=True,
|
|
33
36
|
cwd=cwd or os.getcwd(),
|
|
34
37
|
env=run_env,
|
|
@@ -51,7 +54,7 @@ def run_cli(
|
|
|
51
54
|
)
|
|
52
55
|
|
|
53
56
|
out = r.stdout.decode("utf-8", "replace").strip()
|
|
54
|
-
if not out:
|
|
57
|
+
if not out or not json_output:
|
|
55
58
|
return None
|
|
56
59
|
|
|
57
60
|
try:
|
|
@@ -9,6 +9,7 @@ from typing import Any
|
|
|
9
9
|
from . import discover as _discover
|
|
10
10
|
from . import manage as _manage
|
|
11
11
|
from . import schema as _schema
|
|
12
|
+
from .errors import SwytchcodeError
|
|
12
13
|
from .exec import exec_ as _exec
|
|
13
14
|
from .providers.base import Provider, Tool
|
|
14
15
|
|
|
@@ -170,7 +171,14 @@ class _Tools:
|
|
|
170
171
|
final_args["body"] = _strip_empty(final_args["body"])
|
|
171
172
|
if isinstance(final_args.get("params"), (dict, list)):
|
|
172
173
|
final_args["params"] = _strip_empty(final_args["params"])
|
|
173
|
-
#
|
|
174
|
+
# A client bound to one end user runs every tool for that user unless
|
|
175
|
+
# the call names another one itself.
|
|
176
|
+
if self._c.tenant_id is not None and options.get("tenant_id") is None:
|
|
177
|
+
options["tenant_id"] = self._c.tenant_id
|
|
178
|
+
if options.get("tenant_label") is None:
|
|
179
|
+
options["tenant_label"] = self._c.tenant_label
|
|
180
|
+
# Forward exec options (dry_run, raw, allow_raw, cwd, env, tenant_id,
|
|
181
|
+
# tenant_label) to the CLI.
|
|
174
182
|
return _exec(canonical_id, final_args, **options)
|
|
175
183
|
|
|
176
184
|
def _tool(self, cid: str) -> Tool:
|
|
@@ -225,8 +233,31 @@ class _Tools:
|
|
|
225
233
|
|
|
226
234
|
|
|
227
235
|
class Swytchcode:
|
|
228
|
-
def __init__(
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
provider: Provider | None = None,
|
|
239
|
+
*,
|
|
240
|
+
tenant_id: str | None = None,
|
|
241
|
+
tenant_label: str | None = None,
|
|
242
|
+
):
|
|
243
|
+
"""
|
|
244
|
+
tenant_id binds the client to one end user of your app: every tool it
|
|
245
|
+
runs, including those an AI agent picks, uses that user's connected
|
|
246
|
+
accounts and never yours. Create one client per request, from your
|
|
247
|
+
server's session. tenant_label is how approvers see that end user, for
|
|
248
|
+
example "Alice Smith (alice@acme.com)".
|
|
249
|
+
"""
|
|
229
250
|
self.provider = provider
|
|
251
|
+
if tenant_id is not None:
|
|
252
|
+
tenant_id = tenant_id.strip()
|
|
253
|
+
# An empty id would quietly run the agent's tools on your own account.
|
|
254
|
+
if not tenant_id:
|
|
255
|
+
raise SwytchcodeError("tenant_id must be a non-empty string when set")
|
|
256
|
+
self.tenant_id = tenant_id
|
|
257
|
+
tenant_label = (tenant_label or "").strip() or None
|
|
258
|
+
if tenant_label and not tenant_id:
|
|
259
|
+
raise SwytchcodeError("tenant_label needs tenant_id")
|
|
260
|
+
self.tenant_label = tenant_label
|
|
230
261
|
self.tools = _Tools(self)
|
|
231
262
|
|
|
232
263
|
def handle_tool_calls(
|
|
@@ -80,6 +80,8 @@ def exec_(
|
|
|
80
80
|
dry_run: bool = False,
|
|
81
81
|
allow_raw: bool = False,
|
|
82
82
|
timeout: float | None = None,
|
|
83
|
+
tenant_id: str | None = None,
|
|
84
|
+
tenant_label: str | None = None,
|
|
83
85
|
) -> Any:
|
|
84
86
|
"""
|
|
85
87
|
Run swytchcode exec <canonical_id> with optional JSON args on stdin.
|
|
@@ -102,6 +104,14 @@ def exec_(
|
|
|
102
104
|
dry_run: If True, pass --dry-run; request details are output instead of calling the server.
|
|
103
105
|
allow_raw: If True, pass --allow-raw; required for executing raw methods (kernel default is disabled).
|
|
104
106
|
timeout: Optional timeout in seconds for the subprocess.
|
|
107
|
+
tenant_id: Run the call for one end user (tenant) of your app, with their own
|
|
108
|
+
connected account (passes --tenant). Use your own id for the logged-in user,
|
|
109
|
+
taken from your server's session. Never falls back to your own account: if
|
|
110
|
+
this user has not connected the provider, it fails with category
|
|
111
|
+
"tenant_not_connected".
|
|
112
|
+
tenant_label: How approvers see this end user when a policy asks for human
|
|
113
|
+
approval, for example "Alice Smith (alice@acme.com)" (passes
|
|
114
|
+
--tenant-label). Needs tenant_id.
|
|
105
115
|
|
|
106
116
|
Returns:
|
|
107
117
|
Parsed result (any) or raw string when raw is True.
|
|
@@ -112,6 +122,14 @@ def exec_(
|
|
|
112
122
|
canonical_id = canonical_id.strip()
|
|
113
123
|
if not canonical_id:
|
|
114
124
|
raise SwytchcodeError("canonical_id must be a non-empty string")
|
|
125
|
+
# An empty tenant id must never quietly run the call on the developer's own account.
|
|
126
|
+
if tenant_id is not None:
|
|
127
|
+
tenant_id = tenant_id.strip()
|
|
128
|
+
if not tenant_id:
|
|
129
|
+
raise SwytchcodeError("tenant_id must be a non-empty string when set")
|
|
130
|
+
tenant_label = (tenant_label or "").strip()
|
|
131
|
+
if tenant_label and not tenant_id:
|
|
132
|
+
raise SwytchcodeError("tenant_label needs tenant_id")
|
|
115
133
|
|
|
116
134
|
run_env = os.environ.copy()
|
|
117
135
|
if env:
|
|
@@ -124,6 +142,10 @@ def exec_(
|
|
|
124
142
|
cmd.append("--dry-run")
|
|
125
143
|
if allow_raw:
|
|
126
144
|
cmd.append("--allow-raw")
|
|
145
|
+
if tenant_id:
|
|
146
|
+
cmd.extend(["--tenant", tenant_id])
|
|
147
|
+
if tenant_label:
|
|
148
|
+
cmd.extend(["--tenant-label", tenant_label])
|
|
127
149
|
|
|
128
150
|
stdin_payload: bytes | None = None
|
|
129
151
|
if input is not None:
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Connect, save and remove accounts for the end users (tenants) of your app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from .cli import run_cli
|
|
8
|
+
from .errors import SwytchcodeError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _tenant_args(provider: str, tenant_id: str) -> list[str]:
|
|
12
|
+
provider = (provider or "").strip()
|
|
13
|
+
tenant_id = (tenant_id or "").strip()
|
|
14
|
+
if not provider:
|
|
15
|
+
raise SwytchcodeError("provider must be a non-empty string")
|
|
16
|
+
# An empty tenant id would act on the developer's own account.
|
|
17
|
+
if not tenant_id:
|
|
18
|
+
raise SwytchcodeError("tenant_id must be a non-empty string")
|
|
19
|
+
return [provider, "--tenant", tenant_id]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def connect(
|
|
23
|
+
provider: str,
|
|
24
|
+
tenant_id: str,
|
|
25
|
+
*,
|
|
26
|
+
cwd: str | None = None,
|
|
27
|
+
env: dict[str, str] | None = None,
|
|
28
|
+
) -> dict:
|
|
29
|
+
"""
|
|
30
|
+
Start an OAuth connection for one end user and return the link they open.
|
|
31
|
+
|
|
32
|
+
tenant_id is your own id for the end user, taken from your server's session,
|
|
33
|
+
never from the browser. The account is ready once they finish in the popup;
|
|
34
|
+
the first exec() with this tenant_id picks it up. The provider app used
|
|
35
|
+
(Swytchcode's or your own) is the one you chose with `swytchcode auth connect`.
|
|
36
|
+
|
|
37
|
+
Returns {"url": ..., "connected_account_uuid": ...}.
|
|
38
|
+
"""
|
|
39
|
+
out = run_cli(
|
|
40
|
+
["auth", "connect", *_tenant_args(provider, tenant_id), "--json"],
|
|
41
|
+
cwd=cwd,
|
|
42
|
+
env=env,
|
|
43
|
+
)
|
|
44
|
+
if not isinstance(out, dict) or not out.get("authorization_url"):
|
|
45
|
+
raise SwytchcodeError(
|
|
46
|
+
f"{provider} does not connect with OAuth; collect the end user's key "
|
|
47
|
+
"in your app and save it with save_key()",
|
|
48
|
+
out,
|
|
49
|
+
)
|
|
50
|
+
return {
|
|
51
|
+
"url": out["authorization_url"],
|
|
52
|
+
"connected_account_uuid": out.get("connected_account_uuid", ""),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save_key(
|
|
57
|
+
provider: str,
|
|
58
|
+
tenant_id: str,
|
|
59
|
+
key: str,
|
|
60
|
+
*,
|
|
61
|
+
cwd: str | None = None,
|
|
62
|
+
env: dict[str, str] | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Save an end user's API key for a provider that uses keys. The key is stored
|
|
66
|
+
encrypted on this machine only and never sent to Swytchcode; the end user is
|
|
67
|
+
recorded (without the key) toward your plan's end-user limit, so this needs a
|
|
68
|
+
login or API key, and at the limit it raises and nothing is saved.
|
|
69
|
+
"""
|
|
70
|
+
key = (key or "").strip()
|
|
71
|
+
if not key:
|
|
72
|
+
raise SwytchcodeError("key must be a non-empty string")
|
|
73
|
+
out = run_cli(
|
|
74
|
+
["auth", "connect", *_tenant_args(provider, tenant_id), "--json"],
|
|
75
|
+
cwd=cwd,
|
|
76
|
+
env=env,
|
|
77
|
+
input=key + "\n",
|
|
78
|
+
)
|
|
79
|
+
if not isinstance(out, dict) or out.get("stored") != "local":
|
|
80
|
+
raise SwytchcodeError(
|
|
81
|
+
f"{provider} does not use an API key; use connect() instead", out
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save_credentials(
|
|
86
|
+
provider: str,
|
|
87
|
+
tenant_id: str,
|
|
88
|
+
credentials: dict[str, str],
|
|
89
|
+
*,
|
|
90
|
+
cwd: str | None = None,
|
|
91
|
+
env: dict[str, str] | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Save an end user's credentials for a provider whose requests are signed
|
|
95
|
+
(crypto exchanges): several fields, such as ``{"api_key": ..., "secret_key":
|
|
96
|
+
..., "passphrase": ...}``, named as the provider's signing recipe names them
|
|
97
|
+
(``swytchcode info`` shows the auth block). They are stored encrypted on this
|
|
98
|
+
machine only and never sent to Swytchcode; the CLI signs each of this end
|
|
99
|
+
user's requests with them. Like ``save_key()``, this counts the end user
|
|
100
|
+
toward your plan's end-user limit.
|
|
101
|
+
"""
|
|
102
|
+
if not isinstance(credentials, dict):
|
|
103
|
+
raise SwytchcodeError("credentials must be a dict of field names to values")
|
|
104
|
+
fields: dict[str, str] = {}
|
|
105
|
+
for name, value in credentials.items():
|
|
106
|
+
if not isinstance(value, str):
|
|
107
|
+
raise SwytchcodeError(f"credential {name} must be a string")
|
|
108
|
+
if value.strip():
|
|
109
|
+
fields[name] = value.strip()
|
|
110
|
+
if not fields:
|
|
111
|
+
raise SwytchcodeError("credentials must not be empty")
|
|
112
|
+
# One JSON line on stdin: the values never appear in the process arguments.
|
|
113
|
+
out = run_cli(
|
|
114
|
+
["auth", "connect", *_tenant_args(provider, tenant_id), "--json"],
|
|
115
|
+
cwd=cwd,
|
|
116
|
+
env=env,
|
|
117
|
+
input=json.dumps(fields, separators=(",", ":")) + "\n",
|
|
118
|
+
)
|
|
119
|
+
if (
|
|
120
|
+
not isinstance(out, dict)
|
|
121
|
+
or out.get("stored") != "local"
|
|
122
|
+
or out.get("auth_type") != "request_signature"
|
|
123
|
+
):
|
|
124
|
+
raise SwytchcodeError(
|
|
125
|
+
f"{provider} does not use signed requests; use save_key() or connect() instead",
|
|
126
|
+
out,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def disconnect(
|
|
131
|
+
provider: str,
|
|
132
|
+
tenant_id: str,
|
|
133
|
+
*,
|
|
134
|
+
cwd: str | None = None,
|
|
135
|
+
env: dict[str, str] | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Remove one end user's account for a provider, on this machine and on Swytchcode."""
|
|
138
|
+
run_cli(
|
|
139
|
+
["auth", "disconnect", *_tenant_args(provider, tenant_id)],
|
|
140
|
+
cwd=cwd,
|
|
141
|
+
env=env,
|
|
142
|
+
json_output=False,
|
|
143
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swytchcode-runtime
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Thin runtime wrapper around the Swytchcode CLI
|
|
5
5
|
Author: Swytchcode
|
|
6
6
|
License: MIT
|
|
@@ -86,6 +86,8 @@ output = exec("api.report.export", {"id": "123"}, raw=True)
|
|
|
86
86
|
- **raw** - If `True`, use `--raw` and return stdout as a string.
|
|
87
87
|
- **dry_run** - If `True`, pass `--dry-run` to the CLI; request details (method, url, headers, body) are output instead of calling the server.
|
|
88
88
|
- **allow_raw** - If `True`, pass `--allow-raw` to the CLI; required for executing raw methods (kernel has this disabled by default).
|
|
89
|
+
- **tenant_id** - Run the call for one end user of your app, with their own connected account (passes `--tenant`). Never falls back to your account. See [Multi-tenant apps](#multi-tenant-apps).
|
|
90
|
+
- **tenant_label** - How approvers see that end user, e.g. `"Alice Smith (alice@acme.com)"` (passes `--tenant-label`). Needs `tenant_id`.
|
|
89
91
|
|
|
90
92
|
This runtime invokes `swytchcode exec [canonical_id]` with the flags above. For full exec behavior, see the Swytchcode kernel documentation.
|
|
91
93
|
|
|
@@ -121,6 +123,24 @@ except SwytchcodeError as e:
|
|
|
121
123
|
print(e.details.get("suggested_action"))
|
|
122
124
|
```
|
|
123
125
|
|
|
126
|
+
## Multi-tenant apps
|
|
127
|
+
|
|
128
|
+
When your app's own users each connect their accounts, pass your id for the logged-in user (from your server's session, never from the browser) on every call made for them:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from swytchcode_runtime import connect, save_key, save_credentials, disconnect, exec, Swytchcode
|
|
132
|
+
|
|
133
|
+
url = connect("gmail", user.id)["url"] # one-time link (OAuth), open it from the user's click
|
|
134
|
+
save_key("stripe", user.id, key) # API-key providers: stored on your server only
|
|
135
|
+
save_credentials("coinswitch", user.id, # signed requests (crypto exchanges): every field
|
|
136
|
+
{"api_key": api_key, "secret_key": secret_key}) # the exchange needs, stored on your server only
|
|
137
|
+
exec("gmail.user.profile.get", {"params": {"userId": "me"}}, tenant_id=user.id)
|
|
138
|
+
client = Swytchcode(provider, tenant_id=user.id) # agents: every tool runs for this user
|
|
139
|
+
disconnect("gmail", user.id)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
An end user who has not connected raises `SwytchcodeError` with `details["category"] == "tenant_not_connected"`. For exchanges that sign every request, `save_credentials()` takes the fields the provider's signing recipe names (for example `api_key`, `secret_key` and `passphrase`); the CLI signs each of that user's requests with them. Guide: https://docs.swytchcode.com/guides/multi-tenant/
|
|
143
|
+
|
|
124
144
|
## What this library is
|
|
125
145
|
|
|
126
146
|
- A thin process wrapper over the Swytchcode shell.
|
{swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime.egg-info/SOURCES.txt
RENAMED
|
@@ -10,6 +10,7 @@ swytchcode_runtime/exec.py
|
|
|
10
10
|
swytchcode_runtime/manage.py
|
|
11
11
|
swytchcode_runtime/prompts.py
|
|
12
12
|
swytchcode_runtime/schema.py
|
|
13
|
+
swytchcode_runtime/tenants.py
|
|
13
14
|
swytchcode_runtime.egg-info/PKG-INFO
|
|
14
15
|
swytchcode_runtime.egg-info/SOURCES.txt
|
|
15
16
|
swytchcode_runtime.egg-info/dependency_links.txt
|
|
@@ -25,4 +26,5 @@ tests/test_alias.py
|
|
|
25
26
|
tests/test_exec_errors.py
|
|
26
27
|
tests/test_nested_body.py
|
|
27
28
|
tests/test_prompts.py
|
|
28
|
-
tests/test_schema_routing.py
|
|
29
|
+
tests/test_schema_routing.py
|
|
30
|
+
tests/test_tenants.py
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
from swytchcode_runtime import SwytchcodeError, connect, disconnect, exec, save_credentials, save_key
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _done(stdout="", returncode=0, stderr=""):
|
|
10
|
+
return subprocess.CompletedProcess(
|
|
11
|
+
args=[], returncode=returncode, stdout=stdout.encode(), stderr=stderr.encode()
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@mock.patch("swytchcode_runtime.exec._resolve_bin", return_value="swytchcode")
|
|
16
|
+
@mock.patch("swytchcode_runtime.cli._resolve_bin", return_value="swytchcode")
|
|
17
|
+
class TestTenants(unittest.TestCase):
|
|
18
|
+
def test_exec_passes_tenant(self, *_):
|
|
19
|
+
with mock.patch("subprocess.run", return_value=_done('{"ok": true}')) as run:
|
|
20
|
+
exec("gmail.send", {"body": {}}, tenant_id=" alice ")
|
|
21
|
+
self.assertEqual(run.call_args.args[0][-2:], ["--tenant", "alice"])
|
|
22
|
+
|
|
23
|
+
def test_exec_without_tenant_passes_no_flag(self, *_):
|
|
24
|
+
with mock.patch("subprocess.run", return_value=_done("{}")) as run:
|
|
25
|
+
exec("gmail.send", {"body": {}})
|
|
26
|
+
self.assertNotIn("--tenant", run.call_args.args[0])
|
|
27
|
+
|
|
28
|
+
def test_exec_passes_tenant_label(self, *_):
|
|
29
|
+
with mock.patch("subprocess.run", return_value=_done('{"ok": true}')) as run:
|
|
30
|
+
exec("gmail.send", {}, tenant_id="alice", tenant_label=" Alice Smith ")
|
|
31
|
+
self.assertEqual(run.call_args.args[0][-4:], ["--tenant", "alice", "--tenant-label", "Alice Smith"])
|
|
32
|
+
|
|
33
|
+
def test_exec_refuses_label_without_tenant(self, *_):
|
|
34
|
+
with mock.patch("subprocess.run") as run:
|
|
35
|
+
with self.assertRaisesRegex(SwytchcodeError, "tenant_label needs tenant_id"):
|
|
36
|
+
exec("gmail.send", {}, tenant_label="Alice")
|
|
37
|
+
run.assert_not_called()
|
|
38
|
+
|
|
39
|
+
def test_exec_rejects_empty_tenant(self, *_):
|
|
40
|
+
with mock.patch("subprocess.run") as run:
|
|
41
|
+
with self.assertRaises(SwytchcodeError):
|
|
42
|
+
exec("gmail.send", {}, tenant_id=" ")
|
|
43
|
+
run.assert_not_called()
|
|
44
|
+
|
|
45
|
+
def test_exec_reports_tenant_not_connected(self, *_):
|
|
46
|
+
err = json.dumps(
|
|
47
|
+
{
|
|
48
|
+
"error": "alice has not connected google",
|
|
49
|
+
"category": "tenant_not_connected",
|
|
50
|
+
"retryable": False,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
with mock.patch("subprocess.run", return_value=_done("", 3, err)):
|
|
54
|
+
with self.assertRaises(SwytchcodeError) as ctx:
|
|
55
|
+
exec("gmail.send", {}, tenant_id="alice")
|
|
56
|
+
self.assertEqual(ctx.exception.details["category"], "tenant_not_connected")
|
|
57
|
+
|
|
58
|
+
def test_connect_returns_link(self, *_):
|
|
59
|
+
out = json.dumps(
|
|
60
|
+
{
|
|
61
|
+
"authorization_url": "https://auth.example/?state=s",
|
|
62
|
+
"connected_account_uuid": "ca-1",
|
|
63
|
+
"provider_slug": "google",
|
|
64
|
+
"tenant_id": "alice",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
with mock.patch("subprocess.run", return_value=_done(out)) as run:
|
|
68
|
+
res = connect("google", "alice")
|
|
69
|
+
self.assertEqual(
|
|
70
|
+
res, {"url": "https://auth.example/?state=s", "connected_account_uuid": "ca-1"}
|
|
71
|
+
)
|
|
72
|
+
self.assertEqual(
|
|
73
|
+
run.call_args.args[0][1:],
|
|
74
|
+
["auth", "connect", "google", "--tenant", "alice", "--json"],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def test_connect_on_key_provider_points_to_save_key(self, *_):
|
|
78
|
+
with mock.patch("subprocess.run", return_value=_done("")):
|
|
79
|
+
with self.assertRaisesRegex(SwytchcodeError, "save_key"):
|
|
80
|
+
connect("stripe", "alice")
|
|
81
|
+
|
|
82
|
+
def test_save_key_uses_stdin(self, *_):
|
|
83
|
+
out = json.dumps({"provider_slug": "stripe", "tenant_id": "alice", "auth_type": "api_key", "stored": "local"})
|
|
84
|
+
with mock.patch("subprocess.run", return_value=_done(out)) as run:
|
|
85
|
+
save_key("stripe", "alice", "sk_test_123")
|
|
86
|
+
self.assertNotIn("sk_test_123", run.call_args.args[0])
|
|
87
|
+
self.assertEqual(run.call_args.kwargs["input"], b"sk_test_123\n")
|
|
88
|
+
|
|
89
|
+
def test_save_credentials_uses_one_json_line_on_stdin(self, *_):
|
|
90
|
+
out = json.dumps({"provider_slug": "coinswitch", "tenant_id": "alice", "auth_type": "request_signature", "stored": "local"})
|
|
91
|
+
with mock.patch("subprocess.run", return_value=_done(out)) as run:
|
|
92
|
+
save_credentials("coinswitch", "alice", {"api_key": " pub ", "secret_key": "sec"})
|
|
93
|
+
self.assertEqual(run.call_args.args[0][1:], ["auth", "connect", "coinswitch", "--tenant", "alice", "--json"])
|
|
94
|
+
self.assertEqual(run.call_args.kwargs["input"], b'{"api_key":"pub","secret_key":"sec"}\n')
|
|
95
|
+
self.assertNotIn("sec", " ".join(run.call_args.args[0]))
|
|
96
|
+
|
|
97
|
+
def test_save_credentials_on_unsigned_provider_raises(self, *_):
|
|
98
|
+
out = json.dumps({"provider_slug": "stripe", "auth_type": "api_key", "stored": "local"})
|
|
99
|
+
with mock.patch("subprocess.run", return_value=_done(out)):
|
|
100
|
+
with self.assertRaisesRegex(SwytchcodeError, "does not use signed requests"):
|
|
101
|
+
save_credentials("stripe", "alice", {"api_key": "k"})
|
|
102
|
+
|
|
103
|
+
def test_save_credentials_validates_before_running(self, *_):
|
|
104
|
+
with mock.patch("subprocess.run") as run:
|
|
105
|
+
with self.assertRaises(SwytchcodeError):
|
|
106
|
+
save_credentials("x", "alice", {})
|
|
107
|
+
with self.assertRaises(SwytchcodeError):
|
|
108
|
+
save_credentials("x", "alice", {"a": 1})
|
|
109
|
+
run.assert_not_called()
|
|
110
|
+
|
|
111
|
+
def test_disconnect_has_no_json_flag(self, *_):
|
|
112
|
+
with mock.patch("subprocess.run", return_value=_done("Disconnected")) as run:
|
|
113
|
+
disconnect("google", "alice")
|
|
114
|
+
self.assertEqual(
|
|
115
|
+
run.call_args.args[0][1:], ["auth", "disconnect", "google", "--tenant", "alice"]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def test_helpers_refuse_empty_tenant(self, *_):
|
|
119
|
+
with mock.patch("subprocess.run") as run:
|
|
120
|
+
with self.assertRaises(SwytchcodeError):
|
|
121
|
+
connect("google", "")
|
|
122
|
+
with self.assertRaises(SwytchcodeError):
|
|
123
|
+
disconnect("google", " ")
|
|
124
|
+
run.assert_not_called()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
unittest.main()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@mock.patch("swytchcode_runtime.exec._resolve_bin", return_value="swytchcode")
|
|
132
|
+
class TestBoundClient(unittest.TestCase):
|
|
133
|
+
def test_tools_execute_passes_bound_tenant(self, *_):
|
|
134
|
+
from swytchcode_runtime import Swytchcode
|
|
135
|
+
|
|
136
|
+
with mock.patch("subprocess.run", return_value=_done('{"ok": true}')) as run:
|
|
137
|
+
Swytchcode(tenant_id="alice").tools.execute("gmail.send", {"to": "x@y.z"})
|
|
138
|
+
self.assertEqual(run.call_args.args[0][-2:], ["--tenant", "alice"])
|
|
139
|
+
|
|
140
|
+
def test_handle_tool_calls_runs_for_bound_tenant(self, *_):
|
|
141
|
+
from types import SimpleNamespace
|
|
142
|
+
|
|
143
|
+
from swytchcode_runtime import Swytchcode
|
|
144
|
+
|
|
145
|
+
block = SimpleNamespace(type="tool_use", id="t1", name="gmail_send", input={})
|
|
146
|
+
with mock.patch("subprocess.run", return_value=_done("{}")) as run:
|
|
147
|
+
Swytchcode(tenant_id="bob").handle_tool_calls(SimpleNamespace(content=[block]))
|
|
148
|
+
self.assertEqual(run.call_args.args[0][-2:], ["--tenant", "bob"])
|
|
149
|
+
|
|
150
|
+
def test_unbound_client_passes_no_tenant(self, *_):
|
|
151
|
+
from swytchcode_runtime import Swytchcode
|
|
152
|
+
|
|
153
|
+
with mock.patch("subprocess.run", return_value=_done("{}")) as run:
|
|
154
|
+
Swytchcode().tools.execute("gmail.send", {})
|
|
155
|
+
self.assertNotIn("--tenant", run.call_args.args[0])
|
|
156
|
+
|
|
157
|
+
def test_empty_tenant_refused(self, *_):
|
|
158
|
+
from swytchcode_runtime import Swytchcode
|
|
159
|
+
|
|
160
|
+
with self.assertRaises(SwytchcodeError):
|
|
161
|
+
Swytchcode(tenant_id=" ")
|
|
162
|
+
|
|
163
|
+
def test_bound_label_goes_with_bound_tenant_only(self, *_):
|
|
164
|
+
from swytchcode_runtime import Swytchcode
|
|
165
|
+
|
|
166
|
+
client = Swytchcode(tenant_id="alice", tenant_label="Alice Smith")
|
|
167
|
+
with mock.patch("subprocess.run", return_value=_done("{}")) as run:
|
|
168
|
+
client.tools.execute("gmail.send", {})
|
|
169
|
+
alice = run.call_args.args[0]
|
|
170
|
+
client.tools.execute("gmail.send", {}, tenant_id="bob")
|
|
171
|
+
bob = run.call_args.args[0]
|
|
172
|
+
self.assertEqual(alice[-4:], ["--tenant", "alice", "--tenant-label", "Alice Smith"])
|
|
173
|
+
self.assertEqual(bob[-2:], ["--tenant", "bob"])
|
|
174
|
+
with self.assertRaises(SwytchcodeError):
|
|
175
|
+
Swytchcode(tenant_label="Alice")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/__init__.py
RENAMED
|
File without changes
|
{swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/anthropic.py
RENAMED
|
File without changes
|
|
File without changes
|
{swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/crewai.py
RENAMED
|
File without changes
|
{swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/langgraph.py
RENAMED
|
File without changes
|
{swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/openai_agents.py
RENAMED
|
File without changes
|
{swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/vercel.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|