Skip to content

Commit c0ccc0a

Browse files
committed
feat(platform): add context scoped resources and tokens
Signed-off-by: Radek Ježek <radek.jezek@ibm.com>
1 parent 6ab41b9 commit c0ccc0a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+1778
-389
lines changed

apps/beeai-server/src/beeai_server/api/routes/acp.py renamed to apps/__init__.py

File renamed without changes.

apps/beeai-sdk/examples/dependencies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ async def dependent_agent(
2525
llm: Annotated[LLMServiceExtensionServer, LLMServiceExtensionSpec.single_demand()],
2626
) -> AsyncGenerator[RunYield, Message]:
2727
"""Awaits a user message"""
28+
2829
yield trajectory.trajectory_metadata(title="context_param", content=str(context))
2930
yield trajectory.trajectory_metadata(title="message_param", content=str(message.model_dump()))
3031
yield trajectory.message(trajectory_title="llm_param", trajectory_content=str(llm.data))

apps/beeai-sdk/src/beeai_sdk/platform/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
22
# SPDX-License-Identifier: Apache-2.0
33

4-
from .context import *
4+
from .client import *
55
from .file import *
66
from .provider import *
77
from .variables import *
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import os
5+
import ssl
6+
import typing
7+
8+
import httpx
9+
from httpx import URL, AsyncBaseTransport
10+
from httpx._client import EventHook
11+
from httpx._config import DEFAULT_LIMITS, DEFAULT_MAX_REDIRECTS, DEFAULT_TIMEOUT_CONFIG, Limits
12+
from httpx._types import AuthTypes, CertTypes, CookieTypes, HeaderTypes, ProxyTypes, QueryParamTypes, TimeoutTypes
13+
from pydantic import Secret
14+
15+
from beeai_sdk.util import resource_context
16+
17+
18+
class PlatformClient(httpx.AsyncClient):
19+
context_id: str | None = None
20+
21+
def __init__(
22+
self,
23+
context_id: str | None = None, # Enter context scope
24+
auth_token: str | Secret | None = None,
25+
*,
26+
auth: AuthTypes | None = None,
27+
params: QueryParamTypes | None = None,
28+
headers: HeaderTypes | None = None,
29+
cookies: CookieTypes | None = None,
30+
verify: ssl.SSLContext | str | bool = True,
31+
cert: CertTypes | None = None,
32+
http1: bool = True,
33+
http2: bool = False,
34+
proxy: ProxyTypes | None = None,
35+
mounts: None | (typing.Mapping[str, AsyncBaseTransport | None]) = None,
36+
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
37+
follow_redirects: bool = False,
38+
limits: Limits = DEFAULT_LIMITS,
39+
max_redirects: int = DEFAULT_MAX_REDIRECTS,
40+
event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None,
41+
base_url: URL | str = "",
42+
transport: AsyncBaseTransport | None = None,
43+
trust_env: bool = True,
44+
default_encoding: str | typing.Callable[[bytes], str] = "utf-8",
45+
) -> None:
46+
if not base_url:
47+
base_url = os.environ.get("PLATFORM_URL", "http://127.0.0.1:8333")
48+
super().__init__(
49+
auth=auth,
50+
params=params,
51+
headers=headers,
52+
cookies=cookies,
53+
verify=verify,
54+
cert=cert,
55+
http1=http1,
56+
http2=http2,
57+
proxy=proxy,
58+
mounts=mounts,
59+
timeout=timeout,
60+
follow_redirects=follow_redirects,
61+
limits=limits,
62+
max_redirects=max_redirects,
63+
event_hooks=event_hooks,
64+
base_url=base_url,
65+
transport=transport,
66+
trust_env=trust_env,
67+
default_encoding=default_encoding,
68+
)
69+
self.context_id = context_id
70+
if auth_token:
71+
self.headers["Authorization"] = f"Bearer {auth_token}"
72+
73+
74+
get_platform_client, use_platform_client = resource_context(factory=PlatformClient, default_factory=PlatformClient)
75+
76+
__all__ = ["PlatformClient", "get_platform_client", "use_platform_client"]
Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,105 @@
11
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
22
# SPDX-License-Identifier: Apache-2.0
33

4-
import os
4+
from __future__ import annotations
55

6-
import httpx
6+
from typing import Literal
77

8-
from beeai_sdk.util import resource_context
8+
import pydantic
99

10-
get_platform_client, use_platform_client = resource_context(
11-
factory=httpx.AsyncClient,
12-
default_factory=lambda: httpx.AsyncClient(base_url=os.environ.get("PLATFORM_URL", "http://127.0.0.1:8333")),
13-
)
10+
from beeai_sdk.platform.client import PlatformClient, get_platform_client
11+
from beeai_sdk.platform.types import Metadata
1412

15-
__all__ = [
16-
"get_platform_client",
17-
"use_platform_client",
18-
]
13+
14+
class ContextToken(pydantic.BaseModel):
15+
token: pydantic.Secret[str]
16+
expires_at: pydantic.AwareDatetime | None = None
17+
18+
19+
class ResourceIdPermission(pydantic.BaseModel):
20+
id: str
21+
22+
23+
class Permissions(pydantic.BaseModel):
24+
files: set[Literal["read", "write", "extract", "*"]] = set()
25+
vector_stores: set[Literal["read", "write", "extract", "*"]] = set()
26+
llm: set[Literal["*"] | ResourceIdPermission] = set()
27+
embeddings: set[Literal["*"] | ResourceIdPermission] = set()
28+
a2a_proxy: set[Literal["*"]] = set()
29+
providers: set[Literal["read", "write", "*"]] = set() # write includes "show logs" permission
30+
variables: set[Literal["read", "write", "*"]] = set()
31+
contexts: set[Literal["read", "write", "*"]] = set()
32+
33+
34+
class Context(pydantic.BaseModel):
35+
id: str
36+
created_at: pydantic.AwareDatetime
37+
updated_at: pydantic.AwareDatetime
38+
last_active_at: pydantic.AwareDatetime
39+
created_by: str
40+
metadata: Metadata | None = None
41+
42+
@staticmethod
43+
async def create(
44+
*,
45+
metadata: Metadata | None = None,
46+
client: PlatformClient | None = None,
47+
) -> Context:
48+
return pydantic.TypeAdapter(Context).validate_python(
49+
(await (client or get_platform_client()).post(url="/api/v1/contexts", json={"metadata": metadata}))
50+
.raise_for_status()
51+
.json()
52+
)
53+
54+
async def get(
55+
self: Context | str,
56+
*,
57+
client: PlatformClient | None = None,
58+
) -> Context:
59+
# `self` has a weird type so that you can call both `instance.get()` to update an instance, or `File.get("123")` to obtain a new instance
60+
context_id = self if isinstance(self, str) else self.id
61+
return pydantic.TypeAdapter(Context).validate_python(
62+
(await (client or get_platform_client()).get(url=f"/api/v1/contexts/{context_id}"))
63+
.raise_for_status()
64+
.json()
65+
)
66+
67+
async def delete(
68+
self: Context | str,
69+
*,
70+
client: PlatformClient | None = None,
71+
) -> None:
72+
# `self` has a weird type so that you can call both `instance.delete()` or `File.delete("123")`
73+
context_id = self if isinstance(self, str) else self.id
74+
_ = (await (client or get_platform_client()).delete(url=f"/api/v1/contexts/{context_id}")).raise_for_status()
75+
76+
async def generate_token(
77+
self: Context | str,
78+
*,
79+
client: PlatformClient | None = None,
80+
grant_global_permissions: Permissions | None = None,
81+
grant_context_permissions: Permissions | None = None,
82+
) -> ContextToken:
83+
"""
84+
Generate token for agent authentication
85+
86+
@param grant_global_permissions: Global permissions granted by the token. Must be subset of the users permissions
87+
@param grant_context_permissions: Context permissions granted by the token. Must be subset of the users permissions
88+
"""
89+
# `self` has a weird type so that you can call both `instance.content()` to get content of an instance, or `File.content("123")`
90+
context_id = self if isinstance(self, str) else self.id
91+
grant_global_permissions = grant_global_permissions or Permissions()
92+
grant_context_permissions = grant_context_permissions or Permissions()
93+
return pydantic.TypeAdapter(ContextToken).validate_python(
94+
(
95+
await (client or get_platform_client()).post(
96+
url=f"/api/v1/contexts/{context_id}/token",
97+
json={
98+
"grant_global_permissions": grant_global_permissions.model_dump(mode="json"),
99+
"grant_context_permissions": grant_context_permissions.model_dump(mode="json"),
100+
},
101+
)
102+
)
103+
.raise_for_status()
104+
.json()
105+
)

apps/beeai-sdk/src/beeai_sdk/platform/file.py

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
from __future__ import annotations
55

66
import typing
7+
from typing import Literal
78

8-
import httpx
99
import pydantic
1010

11-
from beeai_sdk.platform.context import get_platform_client
11+
from beeai_sdk.platform.client import PlatformClient, get_platform_client
1212

1313

1414
class Extraction(pydantic.BaseModel):
@@ -39,13 +39,17 @@ async def create(
3939
filename: str,
4040
content: typing.BinaryIO | bytes,
4141
content_type: str = "application/octet-stream",
42-
client: httpx.AsyncClient | None = None,
42+
client: PlatformClient | None = None,
43+
context_id: str | None | Literal["auto"] = "auto",
4344
) -> File:
45+
platform_client = client or get_platform_client()
46+
context_id = platform_client.context_id if context_id == "auto" else context_id
4447
return pydantic.TypeAdapter(File).validate_python(
4548
(
46-
await (client or get_platform_client()).post(
49+
await platform_client.post(
4750
url="/api/v1/files",
4851
files={"file": (filename, content, content_type)},
52+
params=context_id and {"context_id": context_id},
4953
)
5054
)
5155
.raise_for_status()
@@ -55,60 +59,96 @@ async def create(
5559
async def get(
5660
self: File | str,
5761
*,
58-
client: httpx.AsyncClient | None = None,
62+
client: PlatformClient | None = None,
63+
context_id: str | None | Literal["auto"] = "auto",
5964
) -> File:
6065
# `self` has a weird type so that you can call both `instance.get()` to update an instance, or `File.get("123")` to obtain a new instance
6166
file_id = self if isinstance(self, str) else self.id
67+
platform_client = client or get_platform_client()
68+
context_id = platform_client.context_id if context_id == "auto" else context_id
6269
return pydantic.TypeAdapter(File).validate_python(
63-
(await (client or get_platform_client()).get(url=f"/api/v1/files/{file_id}")).raise_for_status().json()
70+
(
71+
await platform_client.get(
72+
url=f"/api/v1/files/{file_id}",
73+
params=context_id and {"context_id": context_id},
74+
)
75+
)
76+
.raise_for_status()
77+
.json()
6478
)
6579

6680
async def delete(
6781
self: File | str,
6882
*,
69-
client: httpx.AsyncClient | None = None,
83+
client: PlatformClient | None = None,
84+
context_id: str | None | Literal["auto"] = "auto",
7085
) -> None:
7186
# `self` has a weird type so that you can call both `instance.delete()` or `File.delete("123")`
7287
file_id = self if isinstance(self, str) else self.id
73-
_ = (await (client or get_platform_client()).delete(url=f"/api/v1/files/{file_id}")).raise_for_status()
88+
platform_client = client or get_platform_client()
89+
context_id = platform_client.context_id if context_id == "auto" else context_id
90+
_ = (
91+
await platform_client.delete(
92+
url=f"/api/v1/files/{file_id}", params=context_id and {"context_id": context_id}
93+
)
94+
).raise_for_status()
7495

7596
async def content(
7697
self: File | str,
7798
*,
78-
client: httpx.AsyncClient | None = None,
99+
client: PlatformClient | None = None,
100+
context_id: str | None | Literal["auto"] = "auto",
79101
) -> str:
80102
# `self` has a weird type so that you can call both `instance.content()` to get content of an instance, or `File.content("123")`
81103
file_id = self if isinstance(self, str) else self.id
104+
platform_client = client or get_platform_client()
105+
context_id = platform_client.context_id if context_id == "auto" else context_id
82106
return (
83-
(await (client or get_platform_client()).get(url=f"/api/v1/files/{file_id}/content"))
107+
(
108+
await platform_client.get(
109+
url=f"/api/v1/files/{file_id}/content", params=context_id and {"context_id": context_id}
110+
)
111+
)
84112
.raise_for_status()
85113
.text
86114
)
87115

88116
async def text_content(
89117
self: File | str,
90118
*,
91-
client: httpx.AsyncClient | None = None,
119+
client: PlatformClient | None = None,
120+
context_id: str | None | Literal["auto"] = "auto",
92121
) -> str:
93122
# `self` has a weird type so that you can call both `instance.text_content()` to get text content of an instance, or `File.text_content("123")`
94123
file_id = self if isinstance(self, str) else self.id
124+
platform_client = client or get_platform_client()
125+
context_id = platform_client.context_id if context_id == "auto" else context_id
95126
return (
96-
(await (client or get_platform_client()).get(url=f"/api/v1/files/{file_id}/text_content"))
127+
(
128+
await platform_client.get(
129+
url=f"/api/v1/files/{file_id}/text_content",
130+
params=context_id and {"context_id": context_id},
131+
)
132+
)
97133
.raise_for_status()
98134
.text
99135
)
100136

101137
async def create_extraction(
102138
self: File | str,
103139
*,
104-
client: httpx.AsyncClient | None = None,
140+
client: PlatformClient | None = None,
141+
context_id: str | None | Literal["auto"] = "auto",
105142
) -> Extraction:
106143
# `self` has a weird type so that you can call both `instance.create_extraction()` to create an extraction for an instance, or `File.create_extraction("123")`
107144
file_id = self if isinstance(self, str) else self.id
145+
platform_client = client or get_platform_client()
146+
context_id = platform_client.context_id if context_id == "auto" else context_id
108147
return pydantic.TypeAdapter(Extraction).validate_python(
109148
(
110-
await (client or get_platform_client()).post(
149+
await platform_client.post(
111150
url=f"/api/v1/files/{file_id}/extraction",
151+
params=context_id and {"context_id": context_id},
112152
)
113153
)
114154
.raise_for_status()
@@ -118,14 +158,18 @@ async def create_extraction(
118158
async def get_extraction(
119159
self: File | str,
120160
*,
121-
client: httpx.AsyncClient | None = None,
161+
client: PlatformClient | None = None,
162+
context_id: str | None | Literal["auto"] = "auto",
122163
) -> Extraction:
123164
# `self` has a weird type so that you can call both `instance.get_extraction()` to get an extraction of an instance, or `File.get_extraction("123", "456")`
124165
file_id = self if isinstance(self, str) else self.id
166+
platform_client = client or get_platform_client()
167+
context_id = platform_client.context_id if context_id == "auto" else context_id
125168
return pydantic.TypeAdapter(Extraction).validate_python(
126169
(
127-
await (client or get_platform_client()).get(
170+
await platform_client.get(
128171
url=f"/api/v1/files/{file_id}/extraction",
172+
params=context_id and {"context_id": context_id},
129173
)
130174
)
131175
.raise_for_status()
@@ -135,10 +179,16 @@ async def get_extraction(
135179
async def delete_extraction(
136180
self: File | str,
137181
*,
138-
client: httpx.AsyncClient | None = None,
182+
client: PlatformClient | None = None,
183+
context_id: str | None | Literal["auto"] = "auto",
139184
) -> None:
140185
# `self` has a weird type so that you can call both `instance.delete_extraction()` or `File.delete_extraction("123", "456")`
141186
file_id = self if isinstance(self, str) else self.id
187+
platform_client = client or get_platform_client()
188+
context_id = platform_client.context_id if context_id == "auto" else context_id
142189
_ = (
143-
await (client or get_platform_client()).delete(url=f"/api/v1/files/{file_id}/extraction")
190+
await platform_client.delete(
191+
url=f"/api/v1/files/{file_id}/extraction",
192+
params=context_id and {"context_id": context_id},
193+
)
144194
).raise_for_status()

0 commit comments

Comments
 (0)