Skip to content

Commit 029582b

Browse files
committed
feat(sessions): identify SDK-created sessions without overwriting metadata
1 parent b38d16b commit 029582b

7 files changed

Lines changed: 191 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ existing `0.1.0` release; earlier development prereleases are not listed.
77

88
### Added
99

10+
- Automatically mark direct Forward and Managed Session creation with `__qca_sdk_created`, `__qca_sdk_language`, and the actual `__qca_sdk_version`, preserving caller metadata.
1011
- Add Forward Dreams (create, list, retrieve, cancel, archive), channel pairing list/retrieve/update, and Managed OAuth start with typed requests and responses.
1112
- Add Identity automatic memory-store control, mount access and cursor pagination, sparse managed-capability overrides, and full tools-array replacement.
1213
- Support string Template references and Advisor model entries in Forward Multiagent rosters, Schedule file resources, and optional Run input snapshots.
@@ -18,12 +19,14 @@ existing `0.1.0` release; earlier development prereleases are not listed.
1819

1920
### Breaking changes
2021

22+
- Session creation rejects conflicting values for the three reserved SDK metadata keys before sending; matching string values remain accepted. Missing or null metadata now contains the markers, which count toward API metadata limits.
2123
- Forward event `rubric` is a text/content or file/file_id object instead of a string.
2224
- Managed Agent `effort` is a level string instead of an object; Agent and Dream model configuration no longer declares the unsupported `speed` field.
2325
- Identity mount lists now return standard pages (and an `AsyncPaginator` for the async client). Existing awaited calls still yield a page; use normal or async iteration to traverse all pages.
2426

2527
### Migration
2628

29+
- Rename business fields that collide with the three `__qca_sdk_` keys and leave space for automatic markers. See “SDK-created Session metadata” in the README.
2730
- Send `rubric={"type": "text", "content": "..."}` and `model={"id": "ultimate", "effort": "high"}`; remove Managed `speed` arguments. See the README's “Official contract alignment” section.
2831

2932
## [0.12.0]

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,32 @@ Under the hood these run `pydoc-markdown` via `uv` on a pinned Python 3.12:
4141
uv run --python 3.12 --extra dev --locked pydoc-markdown pydoc-markdown.yml
4242
```
4343

44+
## SDK-created Session metadata
45+
46+
Direct Forward and Managed Session creation automatically adds these string-valued
47+
metadata fields (Python includes both sync and async clients):
48+
49+
| Field | Value |
50+
| --- | --- |
51+
| `__qca_sdk_created` | `"true"` |
52+
| `__qca_sdk_language` | `"python"` |
53+
| `__qca_sdk_version` | The installed SDK's version |
54+
55+
Reserve the `__qca_sdk_` prefix for SDK provenance. Business metadata is preserved,
56+
and the caller's metadata object is not mutated. Omitted or null metadata becomes
57+
an object containing the markers. Passing an existing marker with the exact same
58+
string value is accepted; a different value or type (including null) fails locally
59+
before any HTTP request. This also applies after `extra_body` overrides are merged.
60+
61+
Session updates, reads and event operations do not inject or repair these fields.
62+
Sessions created indirectly through Schedules, Batches or Realtime are outside this
63+
behavior. These markers describe the creating SDK; they are client-supplied metadata,
64+
not an authentication or authorization mechanism.
65+
66+
Migration: rename any business fields that already use these three keys before
67+
upgrading. Automatic markers count toward API metadata limits; the SDK never drops
68+
business fields to make room. No additional parameters or opt-in are required.
69+
4470
## Usage
4571

4672
```python

‎docs/api/reference.md‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,6 +1315,20 @@ async def __aexit__(*_: object) -> None
13151315

13161316
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_resumable_streaming.py)
13171317

1318+
<a id="qca.common._session_metadata"></a>
1319+
1320+
# qca.common.\_session\_metadata
1321+
1322+
<a id="qca.common._session_metadata.with_session_metadata"></a>
1323+
1324+
#### with\_session\_metadata
1325+
1326+
```python
1327+
def with_session_metadata(body: Mapping[str, Any]) -> dict[str, Any]
1328+
```
1329+
1330+
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_session_metadata.py)
1331+
13181332
<a id="qca.common._streaming"></a>
13191333

13201334
# qca.common.\_streaming
@@ -8454,7 +8468,7 @@ def create(
84548468

84558469
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/sessions/sessions.py)
84568470

8457-
POST /sessions.
8471+
POST /sessions. Adds __qca_sdk_ metadata; conflicting reserved values raise ValueError.
84588472

84598473
<a id="qca.forward.resources.sessions.sessions.Sessions.retrieve"></a>
84608474

@@ -8642,7 +8656,7 @@ async def create(
86428656

86438657
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/sessions/sessions.py)
86448658

8645-
POST /sessions.
8659+
POST /sessions. Adds __qca_sdk_ metadata; conflicting reserved values raise ValueError.
86468660

86478661
<a id="qca.forward.resources.sessions.sessions.AsyncSessions.retrieve"></a>
86488662

@@ -24670,7 +24684,7 @@ def create(
2467024684

2467124685
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/sessions/sessions.py)
2467224686

24673-
POST /sessions.
24687+
POST /sessions. Adds __qca_sdk_ metadata; conflicting reserved values raise ValueError.
2467424688

2467524689
<a id="qca.managed.resources.sessions.sessions.Sessions.retrieve"></a>
2467624690

@@ -24940,7 +24954,7 @@ async def create(
2494024954

2494124955
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/sessions/sessions.py)
2494224956

24943-
POST /sessions.
24957+
POST /sessions. Adds __qca_sdk_ metadata; conflicting reserved values raise ValueError.
2494424958

2494524959
<a id="qca.managed.resources.sessions.sessions.AsyncSessions.retrieve"></a>
2494624960

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Mapping
4+
5+
from qca._version import __version__
6+
7+
from ._types import NotGiven
8+
9+
10+
def with_session_metadata(body: Mapping[str, Any]) -> dict[str, Any]:
11+
metadata = body.get("metadata")
12+
if metadata is None or isinstance(metadata, NotGiven):
13+
metadata = {}
14+
if not isinstance(metadata, Mapping):
15+
raise ValueError("session creation metadata must be a JSON object or null")
16+
merged = {key: value for key, value in metadata.items() if not isinstance(value, NotGiven)}
17+
for key, expected in {
18+
"__qca_sdk_created": "true",
19+
"__qca_sdk_language": "python",
20+
"__qca_sdk_version": __version__,
21+
}.items():
22+
if key in merged and (not isinstance(merged[key], str) or merged[key] != expected):
23+
raise ValueError(f"conflicting SDK metadata field: {key}")
24+
merged[key] = expected
25+
return {**body, "metadata": merged}

‎src/qca/forward/resources/sessions/sessions.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import httpx
88

99
from qca.common._resource import AsyncAPIResource, SyncAPIResource
10+
from qca.common._session_metadata import with_session_metadata
1011
from qca.common._types import NOT_GIVEN, NotGiven
1112
from qca.common._utils import make_request_options, path_template
1213
from qca.common.pagination import AsyncPaginator, SyncPage
@@ -105,7 +106,7 @@ def create(
105106
extra_body: Dict[str, Any] | None = None,
106107
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
107108
) -> Session:
108-
"""POST /sessions."""
109+
"""POST /sessions. Adds __qca_sdk_ metadata; conflicting reserved values raise ValueError."""
109110
_path = path_template("/sessions")
110111
options = make_request_options(
111112
body={
@@ -123,6 +124,7 @@ def create(
123124
extra_body=extra_body,
124125
timeout=timeout,
125126
)
127+
options["body"] = with_session_metadata(options["body"])
126128
return self._client.request("POST", _path, cast_to=Session, options=options)
127129

128130
def retrieve(
@@ -306,7 +308,7 @@ async def create(
306308
extra_body: Dict[str, Any] | None = None,
307309
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
308310
) -> Session:
309-
"""POST /sessions."""
311+
"""POST /sessions. Adds __qca_sdk_ metadata; conflicting reserved values raise ValueError."""
310312
_path = path_template("/sessions")
311313
options = make_request_options(
312314
body={
@@ -324,6 +326,7 @@ async def create(
324326
extra_body=extra_body,
325327
timeout=timeout,
326328
)
329+
options["body"] = with_session_metadata(options["body"])
327330
return await self._client.request("POST", _path, cast_to=Session, options=options)
328331

329332
async def retrieve(

‎src/qca/managed/resources/sessions/sessions.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import httpx
88

99
from qca.common._resource import AsyncAPIResource, SyncAPIResource
10+
from qca.common._session_metadata import with_session_metadata
1011
from qca.common._types import NOT_GIVEN, NotGiven
1112
from qca.common._utils import make_request_options, path_template
1213
from qca.common.pagination import AsyncPaginator, SyncPage
@@ -102,7 +103,7 @@ def create(
102103
extra_body: Dict[str, Any] | None = None,
103104
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
104105
) -> Session:
105-
"""POST /sessions."""
106+
"""POST /sessions. Adds __qca_sdk_ metadata; conflicting reserved values raise ValueError."""
106107
_path = path_template("/sessions")
107108
options = make_request_options(
108109
body={
@@ -123,6 +124,7 @@ def create(
123124
extra_body=extra_body,
124125
timeout=timeout,
125126
)
127+
options["body"] = with_session_metadata(options["body"])
126128
return self._client.request("POST", _path, cast_to=Session, options=options)
127129

128130
def retrieve(
@@ -419,7 +421,7 @@ async def create(
419421
extra_body: Dict[str, Any] | None = None,
420422
timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
421423
) -> Session:
422-
"""POST /sessions."""
424+
"""POST /sessions. Adds __qca_sdk_ metadata; conflicting reserved values raise ValueError."""
423425
_path = path_template("/sessions")
424426
options = make_request_options(
425427
body={
@@ -440,6 +442,7 @@ async def create(
440442
extra_body=extra_body,
441443
timeout=timeout,
442444
)
445+
options["body"] = with_session_metadata(options["body"])
443446
return await self._client.request("POST", _path, cast_to=Session, options=options)
444447

445448
async def retrieve(

‎tests/test_session_metadata.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import inspect
2+
import json
3+
from copy import deepcopy
4+
5+
import httpx
6+
import pytest
7+
8+
from qca import AsyncForward, AsyncManaged, Forward, Managed
9+
from qca._version import __version__
10+
from qca.common._types import NOT_GIVEN
11+
12+
STAMP = {
13+
"__qca_sdk_created": "true",
14+
"__qca_sdk_language": "python",
15+
"__qca_sdk_version": __version__,
16+
}
17+
18+
19+
async def resolve(value):
20+
return await value if inspect.isawaitable(value) else value
21+
22+
23+
@pytest.fixture(params=[("forward", False), ("forward", True), ("managed", False), ("managed", True)])
24+
async def session_client(request):
25+
mode, async_ = request.param
26+
recorded = []
27+
28+
def handle(req):
29+
recorded.append(req)
30+
return httpx.Response(200, json={"id": "sess_test"})
31+
32+
cls = {
33+
(False, "forward"): Forward,
34+
(True, "forward"): AsyncForward,
35+
(False, "managed"): Managed,
36+
(True, "managed"): AsyncManaged,
37+
}[async_, mode]
38+
http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
39+
client = cls(pat="test", http_client=http, max_retries=0)
40+
params = (
41+
{"identity_id": "idn", "template_id": "tmpl"}
42+
if mode == "forward"
43+
else {"agent": "agent", "environment_id": "env"}
44+
)
45+
yield client, params, recorded, mode
46+
await resolve(client.close())
47+
48+
49+
@pytest.mark.parametrize("metadata", [NOT_GIVEN, None, {}, {"ticket": "A-123"}, STAMP])
50+
async def test_create_stamps_metadata_without_mutating_input(session_client, metadata):
51+
client, params, recorded, _ = session_client
52+
original = deepcopy(metadata) if isinstance(metadata, dict) else metadata
53+
for _ in range(2):
54+
await resolve(client.sessions.create(**params, metadata=metadata))
55+
expected = {**(metadata if isinstance(metadata, dict) else {}), **STAMP}
56+
for req in recorded:
57+
assert req.method == "POST" and req.url.path.endswith("/sessions")
58+
assert json.loads(req.content) == {**params, "metadata": expected}
59+
assert req.headers["x-qoder-package-version"] == expected["__qca_sdk_version"]
60+
assert metadata == original
61+
62+
63+
@pytest.mark.parametrize("key", STAMP)
64+
@pytest.mark.parametrize("value", ["user-value", None, True, 12])
65+
@pytest.mark.parametrize("extra", [False, True])
66+
async def test_conflicts_fail_before_transport(session_client, key, value, extra):
67+
client, params, recorded, _ = session_client
68+
metadata = {"ticket": "A-123", key: value}
69+
kwargs = {"extra_body": {"metadata": metadata}} if extra else {"metadata": metadata}
70+
with pytest.raises(ValueError, match=f"conflicting SDK metadata field: {key}") as exc:
71+
await resolve(client.sessions.create(**params, **kwargs))
72+
assert "user-value" not in str(exc.value)
73+
assert metadata == {"ticket": "A-123", key: value}
74+
assert not recorded
75+
76+
77+
async def test_extra_body_and_headers_keep_real_sdk_version(session_client):
78+
client, params, recorded, mode = session_client
79+
metadata = {"ticket": "extra", "__qca_sdk_future": "preserved"}
80+
if mode == "forward":
81+
metadata["nested"] = {"items": [1, False, None]}
82+
original = deepcopy(metadata)
83+
await resolve(
84+
client.sessions.create(
85+
**params,
86+
metadata={"replaced": "typed"},
87+
extra_body={"metadata": metadata},
88+
extra_headers={"X-Qoder-Package-Version": "custom-header"},
89+
)
90+
)
91+
assert json.loads(recorded[0].content) == {**params, "metadata": {**original, **STAMP}}
92+
assert metadata == original
93+
94+
95+
@pytest.mark.parametrize("metadata", [[], "invalid", 1, False])
96+
async def test_invalid_metadata_fails_before_transport(session_client, metadata):
97+
client, params, recorded, _ = session_client
98+
with pytest.raises(ValueError, match="metadata must be a JSON object or null"):
99+
await resolve(client.sessions.create(**params, extra_body={"metadata": metadata}))
100+
assert not recorded
101+
102+
103+
async def test_update_and_retrieve_do_not_stamp_metadata(session_client):
104+
client, _, recorded, _ = session_client
105+
metadata = {"ticket": "A-123", "__qca_sdk_language": "historic"}
106+
await resolve(client.sessions.update("sess_test", metadata=metadata))
107+
await resolve(client.sessions.retrieve("sess_test"))
108+
assert json.loads(recorded[0].content) == {"metadata": metadata}
109+
assert not recorded[1].content

0 commit comments

Comments
 (0)