Skip to content

Commit f660cc9

Browse files
Add support for create_agent_account Provisioning API
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dd81b67 commit f660cc9

5 files changed

Lines changed: 168 additions & 15 deletions

File tree

cloudinary/api_client/call_account_api.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,43 @@
88
_http = get_http_connector(account_config(), cloudinary.CERT_KWARGS)
99

1010

11+
# Account-scoped, authenticated call: provisioning/accounts/{account_id}/...
1112
def _call_account_api(method, uri, params=None, headers=None, **options):
12-
prefix = options.pop("upload_prefix",
13-
cloudinary.config().upload_prefix) or "https://api.cloudinary.com"
13+
account_uri = [ACCOUNT_SUB_PATH, _account_id(options)] + uri
14+
return _execute_account_request(method, account_uri, _account_auth(options),
15+
params=params, headers=headers, **options)
16+
17+
18+
# Public, unauthenticated call: provisioning/... with no account_id or credentials
19+
def _call_public_account_api(method, uri, params=None, headers=None, **options):
20+
return _execute_account_request(method, uri, {"anonymous": True},
21+
params=params, headers=headers, **options)
22+
23+
24+
def _account_id(options):
1425
account_id = options.pop("account_id", account_config().account_id)
1526
if not account_id:
1627
raise Exception("Must supply account_id")
28+
return account_id
29+
30+
31+
def _account_auth(options):
1732
provisioning_api_key = options.pop("provisioning_api_key", account_config().provisioning_api_key)
1833
if not provisioning_api_key:
1934
raise Exception("Must supply provisioning_api_key")
20-
provisioning_api_secret = options.pop("provisioning_api_secret",
21-
account_config().provisioning_api_secret)
35+
provisioning_api_secret = options.pop("provisioning_api_secret", account_config().provisioning_api_secret)
2236
if not provisioning_api_secret:
2337
raise Exception("Must supply provisioning_api_secret")
24-
provisioning_api_url = "/".join(
25-
[prefix, cloudinary.API_VERSION, PROVISIONING_SUB_PATH, ACCOUNT_SUB_PATH, account_id] + uri)
26-
auth = {"key": provisioning_api_key, "secret": provisioning_api_secret}
38+
return {"key": provisioning_api_key, "secret": provisioning_api_secret}
39+
40+
41+
# Core transport: builds the provisioning URL and dispatches with the resolved auth.
42+
# The API version can be overridden via the "api_version" option (defaults to cloudinary.API_VERSION).
43+
def _execute_account_request(method, uri, auth, params=None, headers=None, **options):
44+
prefix = options.pop("upload_prefix",
45+
cloudinary.config().upload_prefix) or "https://api.cloudinary.com"
46+
api_version = options.pop("api_version", cloudinary.API_VERSION)
47+
provisioning_api_url = "/".join([prefix, api_version, PROVISIONING_SUB_PATH] + uri)
2748

2849
return execute_request(http_connector=_http,
2950
method=method,

cloudinary/api_client/execute_request.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ def __init__(self, result, response, **kwargs):
4141

4242

4343
def execute_request(http_connector, method, params, headers, auth, api_url, **options):
44-
# authentication
44+
anonymous = auth.get("anonymous")
4545
key = auth.get("key")
4646
secret = auth.get("secret")
4747
oauth_token = auth.get("oauth_token")
48-
req_headers = urllib3.make_headers(
49-
user_agent=cloudinary.get_user_agent()
50-
)
51-
if oauth_token:
48+
req_headers = urllib3.make_headers(user_agent=cloudinary.get_user_agent())
49+
if anonymous:
50+
pass
51+
elif oauth_token:
5252
req_headers["authorization"] = "Bearer {}".format(oauth_token)
5353
else:
5454
req_headers.update(urllib3.make_headers(basic_auth="{0}:{1}".format(key, secret)))

cloudinary/provisioning/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .account_config import AccountConfig, account_config, reset_config
2-
from .account import (sub_accounts, create_sub_account, delete_sub_account, sub_account, update_sub_account,
2+
from .account import (create_agent_account,
3+
sub_accounts, create_sub_account, delete_sub_account, sub_account, update_sub_account,
34
user_groups, create_user_group, update_user_group, delete_user_group, user_group,
45
add_user_to_group, remove_user_from_group, user_group_users, user_in_user_groups,
56
users, create_user, delete_user, user, update_user, access_keys, generate_access_key,

cloudinary/provisioning/account.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from cloudinary.api_client.call_account_api import _call_account_api
1+
from cloudinary.api_client.call_account_api import _call_account_api, _call_public_account_api, ACCOUNT_SUB_PATH
22
from cloudinary.utils import encode_list
33

4+
AGENTS_SUB_PATH = "agents"
45
SUB_ACCOUNTS_SUB_PATH = "sub_accounts"
56
USERS_SUB_PATH = "users"
67
USER_GROUPS_SUB_PATH = "user_groups"
@@ -20,6 +21,45 @@ class Role(object):
2021
MEDIA_LIBRARY_USER = "media_library_user"
2122

2223

24+
def create_agent_account(email, agent_framework, agent_llm_model, agent_goal, sdk_framework=None, **options):
25+
"""
26+
Create a Cloudinary account on behalf of a human, intended for use by AI agents.
27+
28+
Creates a Free-plan account with a single, initially disabled product environment and sends a
29+
verification email so the human can set a password and activate the account. The returned
30+
credentials are inert until the email is verified.
31+
32+
This endpoint is public and unauthenticated, and is rate-limited per IP address.
33+
34+
:param email: The email address of the human on whose behalf the account is created.
35+
A verification email is sent to this address.
36+
:type email: str
37+
:param agent_framework: The name of the agent framework used to create the account.
38+
Must be between 2 and 100 characters.
39+
:type agent_framework: str
40+
:param agent_llm_model: The LLM model powering the agent. Must be between 2 and 100 characters.
41+
:type agent_llm_model: str
42+
:param agent_goal: A short description of what the agent is trying to achieve.
43+
Must be between 2 and 300 characters.
44+
:type agent_goal: str
45+
:param sdk_framework: The Cloudinary SDK framework the agent intends to use.
46+
Must be between 2 and 100 characters.
47+
:type sdk_framework: str, optional
48+
:param options: Generic advanced options dict, see online documentation
49+
:type options: dict, optional
50+
:return: The created agent account, including inert credentials for its
51+
single product environment
52+
:rtype: dict
53+
"""
54+
uri = [AGENTS_SUB_PATH, ACCOUNT_SUB_PATH]
55+
params = {"email": email,
56+
"agent_framework": agent_framework,
57+
"agent_llm_model": agent_llm_model,
58+
"agent_goal": agent_goal,
59+
"sdk_framework": sdk_framework}
60+
return _call_public_account_api("POST", uri, params=params, **options)
61+
62+
2363
def sub_accounts(enabled=None, ids=None, prefix=None, **options):
2464
"""
2565
List all sub accounts

test/test_provisioning_api.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import unittest
23
from datetime import datetime
34

@@ -8,7 +9,8 @@
89
from cloudinary.provisioning import account_config, reset_config
910
from cloudinary.exceptions import AuthorizationRequired, NotFound
1011

11-
from test.helper_test import UNIQUE_SUB_ACCOUNT_ID, UNIQUE_TEST_ID
12+
from test.helper_test import (UNIQUE_SUB_ACCOUNT_ID, UNIQUE_TEST_ID, URLLIB3_REQUEST, patch, api_response_mock,
13+
get_uri, get_method, get_params, get_headers)
1214

1315
disable_warnings()
1416

@@ -85,6 +87,7 @@ def test_update_sub_account(self):
8587
@unittest.skipUnless(cloudinary.provisioning.account_config().provisioning_api_secret,
8688
"requires provisioning_api_key/provisioning_api_secret")
8789
def test_get_all_sub_accounts(self):
90+
8891
res = cloudinary.provisioning.sub_accounts(True)
8992

9093
sub_account_by_id = [sub_account for sub_account in res["sub_accounts"]
@@ -264,5 +267,93 @@ def test_delete_access_key(self):
264267
self.assertEqual("ok", named_key_del_res["message"])
265268

266269

270+
class CreateAgentAccountTest(unittest.TestCase):
271+
"""
272+
The create agent account endpoint is public, unauthenticated and rate limited per IP,
273+
so it is verified against a mocked transport rather than the live API.
274+
"""
275+
276+
def test_create_agent_account(self):
277+
with patch(URLLIB3_REQUEST) as mocker:
278+
mocker.return_value = api_response_mock()
279+
cloudinary.provisioning.create_agent_account(
280+
"jane@example.com",
281+
agent_framework="langchain",
282+
agent_llm_model="claude-opus-4-8",
283+
agent_goal="Build a product image gallery",
284+
sdk_framework="python",
285+
)
286+
287+
self.assertEqual("POST", get_method(mocker))
288+
self.assertTrue(get_uri(mocker).endswith("/provisioning/agents/accounts"))
289+
290+
params = get_params(mocker)
291+
self.assertEqual("jane@example.com", params["email"])
292+
self.assertEqual("langchain", params["agent_framework"])
293+
self.assertEqual("claude-opus-4-8", params["agent_llm_model"])
294+
self.assertEqual("Build a product image gallery", params["agent_goal"])
295+
self.assertEqual("python", params["sdk_framework"])
296+
297+
def test_create_agent_account_is_unauthenticated(self):
298+
with patch(URLLIB3_REQUEST) as mocker:
299+
mocker.return_value = api_response_mock()
300+
cloudinary.provisioning.create_agent_account(
301+
"jane@example.com",
302+
agent_framework="langchain",
303+
agent_llm_model="claude-opus-4-8",
304+
agent_goal="Build a product image gallery",
305+
)
306+
307+
# The endpoint is public - no authorization header must be sent.
308+
headers = get_headers(mocker)
309+
self.assertNotIn("authorization", {k.lower() for k in headers})
310+
311+
def test_create_agent_account_omits_unset_sdk_framework(self):
312+
with patch(URLLIB3_REQUEST) as mocker:
313+
mocker.return_value = api_response_mock()
314+
cloudinary.provisioning.create_agent_account(
315+
"jane@example.com",
316+
agent_framework="langchain",
317+
agent_llm_model="claude-opus-4-8",
318+
agent_goal="Build a product image gallery",
319+
)
320+
321+
self.assertNotIn("sdk_framework", get_params(mocker))
322+
323+
def test_create_agent_account_parses_response(self):
324+
body = json.dumps({
325+
"external_id": "0aaaaa1bbbbb2ccccc3ddddd4eeeee5f",
326+
"email": "jane@example.com",
327+
"plan_name": "free",
328+
"product_environments": [{
329+
"external_id": "abcde1fghij2klmno3pqrst4uvwxy5z",
330+
"cloud_name": "product1",
331+
"api_key": "123456789012345",
332+
"api_secret": "asdf1JKL2xyz3ABc4s3c5reT01DfaKez",
333+
"api_environment_variable":
334+
"CLOUDINARY_URL=cloudinary://123456789012345:asdf1JKL2xyz3ABc4s3c5reT01DfaKez@product1",
335+
}],
336+
"guidance": "A verification email has been sent to the supplied email address.",
337+
})
338+
with patch(URLLIB3_REQUEST) as mocker:
339+
mocker.return_value = api_response_mock(body)
340+
res = cloudinary.provisioning.create_agent_account(
341+
"jane@example.com",
342+
agent_framework="langchain",
343+
agent_llm_model="claude-opus-4-8",
344+
agent_goal="Build a product image gallery",
345+
)
346+
347+
self.assertEqual("free", res["plan_name"])
348+
self.assertEqual("jane@example.com", res["email"])
349+
self.assertEqual(1, len(res["product_environments"]))
350+
product_environment = res["product_environments"][0]
351+
self.assertEqual("product1", product_environment["cloud_name"])
352+
self.assertEqual("123456789012345", product_environment["api_key"])
353+
self.assertEqual("asdf1JKL2xyz3ABc4s3c5reT01DfaKez", product_environment["api_secret"])
354+
self.assertIn("CLOUDINARY_URL=cloudinary://", product_environment["api_environment_variable"])
355+
self.assertIn("guidance", res)
356+
357+
267358
if __name__ == '__main__':
268359
unittest.main()

0 commit comments

Comments
 (0)