memcode-sdk 2.4.0__tar.gz → 2.5.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.
Files changed (22) hide show
  1. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/PKG-INFO +111 -2
  2. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/README.MD +110 -1
  3. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/__init__.py +1 -1
  4. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/async_v2_client.py +2 -1
  5. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/oauth.py +88 -28
  6. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/v2_client.py +17 -1
  7. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/PKG-INFO +111 -2
  8. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/pyproject.toml +1 -1
  9. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/tests/test_oauth.py +81 -0
  10. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/tests/test_v2_clients.py +47 -0
  11. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/_http.py +0 -0
  12. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/async_client.py +0 -0
  13. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/client.py +0 -0
  14. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/errors.py +0 -0
  15. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/py.typed +0 -0
  16. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/types.py +0 -0
  17. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/v2_types.py +0 -0
  18. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/SOURCES.txt +0 -0
  19. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/dependency_links.txt +0 -0
  20. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/requires.txt +0 -0
  21. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/top_level.txt +0 -0
  22. {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memcode-sdk
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Python SDK for the Memcode long-term memory API
5
5
  Author: Memcode
6
6
  License-Expression: Apache-2.0
@@ -31,7 +31,7 @@ Available in Python, TypeScript, and Go.
31
31
  All three SDKs share the same design principles:
32
32
 
33
33
  - Existing v1 clients keep three 1:1 methods: **ingest**, **retrieve**, **search**
34
- - Bearer-token authentication via constructor arg or `MEMCODE_API_KEY` env var
34
+ - Bearer-token authentication via a static key or refreshable token provider
35
35
  - Typed error hierarchy so callers can handle auth, rate-limit, and server errors distinctly
36
36
  - Zero config defaults — point at `localhost:8000` with no key and it just works in dev
37
37
 
@@ -233,9 +233,26 @@ registration = await oauth.register_client(
233
233
  client_name="My Pipecat agent",
234
234
  redirect_uris=["https://voice.example.com/oauth/callback"],
235
235
  application_type="web",
236
+ software_id="ai.pipecat.memcode",
237
+ software_version="1.0.0",
236
238
  )
237
239
  ```
238
240
 
241
+ `software_id` is a stable public package identifier, not a credential. If it is
242
+ registered by Memcode, loopback or otherwise unverified callbacks can be counted
243
+ under that integration with `attribution_status="unverified"` and
244
+ `attribution_basis="dcr_software_id"`. An exact registered HTTPS callback is
245
+ still required for verified SDK attribution. Conflicting callback and software
246
+ owners are rejected by the authorization server.
247
+
248
+ Memcode may return read-only `integration_id`, `integration_channel`,
249
+ `attribution_status`, and `attribution_basis` values on the registration.
250
+ They are assigned by the server from its trusted integration registry. The SDK
251
+ does not send these values during registration or on Memory API requests, so
252
+ applications need no attribution parameter or additional attribution secret.
253
+ Older servers may omit all four values. Tenant-bound v2 requests reject these
254
+ server-owned field names when supplied in request metadata.
255
+
239
256
  The helper uses Authorization Code with S256 PKCE, resource indicators,
240
257
  rotating refresh tokens, and single-flight refresh per token-store key. A 401
241
258
  causes at most one refresh and one request retry.
@@ -378,6 +395,47 @@ const advancedAnswer = await client.retrieveV2({
378
395
  });
379
396
  ```
380
397
 
398
+ ### OAuth and dynamic bearer tokens
399
+
400
+ TypeScript 2.5 adds `MemcodeOAuthClient`, `OAuthTokenStore`, and
401
+ `DelegatingMemoryTokenProvider`. The flow uses dynamic client registration,
402
+ Authorization Code with S256 PKCE, resource indicators, rotating refresh
403
+ tokens, and revocation:
404
+
405
+ ```typescript
406
+ import { MemcodeClient, MemcodeOAuthClient } from "memcode-sdk";
407
+
408
+ const oauth = new MemcodeOAuthClient({
409
+ issuer: "https://memory.memcode.in/",
410
+ resource: "https://memory.memcode.in",
411
+ clientId: persistedDynamicClientId,
412
+ tokenKey: `voice:${applicationUserId}`,
413
+ tokenStore: encryptedDistributedTokenStore,
414
+ });
415
+
416
+ const request = await oauth.createAuthorizationRequest({
417
+ redirectUri: "https://voice.example.com/oauth/callback",
418
+ });
419
+ await oauth.exchangeCode({
420
+ code: callbackCode,
421
+ returnedState: callbackState,
422
+ authorizationRequest: request,
423
+ });
424
+
425
+ const oauthClient = new MemcodeClient("https://memory.memcode.in", oauth);
426
+ ```
427
+
428
+ Dynamic-registration responses may expose the server-assigned, read-only
429
+ `integrationId`, `integrationChannel`, `attributionStatus`, and
430
+ `attributionBasis` fields. The SDK never sends those values in registration or
431
+ Memory API requests; no integration-supplied attribution parameter or new
432
+ secret is required. All fields remain optional for older servers, and the same
433
+ server-owned names are rejected in tenant-bound v2 request metadata.
434
+
435
+ The legacy function-style `MemcodeV2Client.accessTokenProvider` remains
436
+ supported. A refreshable object provider gets at most one refresh and one
437
+ request retry after a 401.
438
+
381
439
  ### Error handling
382
440
 
383
441
  ```typescript
@@ -515,6 +573,57 @@ func main() {
515
573
  }
516
574
  ```
517
575
 
576
+ ### OAuth and dynamic bearer tokens
577
+
578
+ Go 2.4 adds `OAuthClient`, `OAuthTokenStore`, and
579
+ `DelegatingMemoryTokenProvider`. OAuth operations take `context.Context`; the
580
+ API clients resolve a token per request and perform at most one refresh and one
581
+ retry after a 401.
582
+
583
+ ```go
584
+ store := encryptedDistributedTokenStore
585
+ oauth, err := memcode.NewOAuthClient(memcode.OAuthClientOptions{
586
+ Issuer: "https://memory.memcode.in/",
587
+ Resource: "https://memory.memcode.in",
588
+ ClientID: persistedDynamicClientID,
589
+ TokenKey: "voice:" + applicationUserID,
590
+ TokenStore: store,
591
+ })
592
+ if err != nil {
593
+ panic(err)
594
+ }
595
+
596
+ request, err := oauth.CreateAuthorizationRequest(ctx,
597
+ memcode.CreateAuthorizationRequestOptions{
598
+ RedirectURI: "https://voice.example.com/oauth/callback",
599
+ })
600
+ // Redirect to request.AuthorizationURL and retain request server-side.
601
+ tokens, err := oauth.ExchangeCode(ctx, callbackCode, callbackState, request)
602
+ _ = tokens
603
+
604
+ client, err := memcode.NewClientWithAccessTokenProvider(
605
+ "https://memory.memcode.in",
606
+ oauth,
607
+ )
608
+ ```
609
+
610
+ `RegisterOAuthClientOptions` also accepts `SoftwareID` and `SoftwareVersion`.
611
+ `SoftwareID` is a public analytics identifier, not a credential; when it is the
612
+ only registry match, the server returns unverified `dcr_software_id`
613
+ attribution. An exact registered HTTPS callback is still required for verified
614
+ SDK attribution.
615
+
616
+ `OAuthClientRegistration` may contain read-only `IntegrationID`,
617
+ `IntegrationChannel`, `AttributionStatus`, and `AttributionBasis` values
618
+ assigned by the server. They are not DCR inputs and are never copied to Memory
619
+ API requests, so callers need no attribution parameter or additional secret.
620
+ The fields are empty when an older server omits them. Tenant-bound v2 ingest
621
+ rejects the same server-owned names in request metadata.
622
+
623
+ The supplied store must encrypt tokens, atomically replace rotated token sets,
624
+ and implement `WithRefreshLease` as a distributed lease when multiple workers
625
+ share a grant. `NewInMemoryOAuthTokenStore` is only for tests and examples.
626
+
518
627
  ### Error handling
519
628
 
520
629
  ```go
@@ -8,7 +8,7 @@ Available in Python, TypeScript, and Go.
8
8
  All three SDKs share the same design principles:
9
9
 
10
10
  - Existing v1 clients keep three 1:1 methods: **ingest**, **retrieve**, **search**
11
- - Bearer-token authentication via constructor arg or `MEMCODE_API_KEY` env var
11
+ - Bearer-token authentication via a static key or refreshable token provider
12
12
  - Typed error hierarchy so callers can handle auth, rate-limit, and server errors distinctly
13
13
  - Zero config defaults — point at `localhost:8000` with no key and it just works in dev
14
14
 
@@ -210,9 +210,26 @@ registration = await oauth.register_client(
210
210
  client_name="My Pipecat agent",
211
211
  redirect_uris=["https://voice.example.com/oauth/callback"],
212
212
  application_type="web",
213
+ software_id="ai.pipecat.memcode",
214
+ software_version="1.0.0",
213
215
  )
214
216
  ```
215
217
 
218
+ `software_id` is a stable public package identifier, not a credential. If it is
219
+ registered by Memcode, loopback or otherwise unverified callbacks can be counted
220
+ under that integration with `attribution_status="unverified"` and
221
+ `attribution_basis="dcr_software_id"`. An exact registered HTTPS callback is
222
+ still required for verified SDK attribution. Conflicting callback and software
223
+ owners are rejected by the authorization server.
224
+
225
+ Memcode may return read-only `integration_id`, `integration_channel`,
226
+ `attribution_status`, and `attribution_basis` values on the registration.
227
+ They are assigned by the server from its trusted integration registry. The SDK
228
+ does not send these values during registration or on Memory API requests, so
229
+ applications need no attribution parameter or additional attribution secret.
230
+ Older servers may omit all four values. Tenant-bound v2 requests reject these
231
+ server-owned field names when supplied in request metadata.
232
+
216
233
  The helper uses Authorization Code with S256 PKCE, resource indicators,
217
234
  rotating refresh tokens, and single-flight refresh per token-store key. A 401
218
235
  causes at most one refresh and one request retry.
@@ -355,6 +372,47 @@ const advancedAnswer = await client.retrieveV2({
355
372
  });
356
373
  ```
357
374
 
375
+ ### OAuth and dynamic bearer tokens
376
+
377
+ TypeScript 2.5 adds `MemcodeOAuthClient`, `OAuthTokenStore`, and
378
+ `DelegatingMemoryTokenProvider`. The flow uses dynamic client registration,
379
+ Authorization Code with S256 PKCE, resource indicators, rotating refresh
380
+ tokens, and revocation:
381
+
382
+ ```typescript
383
+ import { MemcodeClient, MemcodeOAuthClient } from "memcode-sdk";
384
+
385
+ const oauth = new MemcodeOAuthClient({
386
+ issuer: "https://memory.memcode.in/",
387
+ resource: "https://memory.memcode.in",
388
+ clientId: persistedDynamicClientId,
389
+ tokenKey: `voice:${applicationUserId}`,
390
+ tokenStore: encryptedDistributedTokenStore,
391
+ });
392
+
393
+ const request = await oauth.createAuthorizationRequest({
394
+ redirectUri: "https://voice.example.com/oauth/callback",
395
+ });
396
+ await oauth.exchangeCode({
397
+ code: callbackCode,
398
+ returnedState: callbackState,
399
+ authorizationRequest: request,
400
+ });
401
+
402
+ const oauthClient = new MemcodeClient("https://memory.memcode.in", oauth);
403
+ ```
404
+
405
+ Dynamic-registration responses may expose the server-assigned, read-only
406
+ `integrationId`, `integrationChannel`, `attributionStatus`, and
407
+ `attributionBasis` fields. The SDK never sends those values in registration or
408
+ Memory API requests; no integration-supplied attribution parameter or new
409
+ secret is required. All fields remain optional for older servers, and the same
410
+ server-owned names are rejected in tenant-bound v2 request metadata.
411
+
412
+ The legacy function-style `MemcodeV2Client.accessTokenProvider` remains
413
+ supported. A refreshable object provider gets at most one refresh and one
414
+ request retry after a 401.
415
+
358
416
  ### Error handling
359
417
 
360
418
  ```typescript
@@ -492,6 +550,57 @@ func main() {
492
550
  }
493
551
  ```
494
552
 
553
+ ### OAuth and dynamic bearer tokens
554
+
555
+ Go 2.4 adds `OAuthClient`, `OAuthTokenStore`, and
556
+ `DelegatingMemoryTokenProvider`. OAuth operations take `context.Context`; the
557
+ API clients resolve a token per request and perform at most one refresh and one
558
+ retry after a 401.
559
+
560
+ ```go
561
+ store := encryptedDistributedTokenStore
562
+ oauth, err := memcode.NewOAuthClient(memcode.OAuthClientOptions{
563
+ Issuer: "https://memory.memcode.in/",
564
+ Resource: "https://memory.memcode.in",
565
+ ClientID: persistedDynamicClientID,
566
+ TokenKey: "voice:" + applicationUserID,
567
+ TokenStore: store,
568
+ })
569
+ if err != nil {
570
+ panic(err)
571
+ }
572
+
573
+ request, err := oauth.CreateAuthorizationRequest(ctx,
574
+ memcode.CreateAuthorizationRequestOptions{
575
+ RedirectURI: "https://voice.example.com/oauth/callback",
576
+ })
577
+ // Redirect to request.AuthorizationURL and retain request server-side.
578
+ tokens, err := oauth.ExchangeCode(ctx, callbackCode, callbackState, request)
579
+ _ = tokens
580
+
581
+ client, err := memcode.NewClientWithAccessTokenProvider(
582
+ "https://memory.memcode.in",
583
+ oauth,
584
+ )
585
+ ```
586
+
587
+ `RegisterOAuthClientOptions` also accepts `SoftwareID` and `SoftwareVersion`.
588
+ `SoftwareID` is a public analytics identifier, not a credential; when it is the
589
+ only registry match, the server returns unverified `dcr_software_id`
590
+ attribution. An exact registered HTTPS callback is still required for verified
591
+ SDK attribution.
592
+
593
+ `OAuthClientRegistration` may contain read-only `IntegrationID`,
594
+ `IntegrationChannel`, `AttributionStatus`, and `AttributionBasis` values
595
+ assigned by the server. They are not DCR inputs and are never copied to Memory
596
+ API requests, so callers need no attribution parameter or additional secret.
597
+ The fields are empty when an older server omits them. Tenant-bound v2 ingest
598
+ rejects the same server-owned names in request metadata.
599
+
600
+ The supplied store must encrypt tokens, atomically replace rotated token sets,
601
+ and implement `WithRefreshLease` as a distributed lease when multiple workers
602
+ share a grant. `NewInMemoryOAuthTokenStore` is only for tests and examples.
603
+
495
604
  ### Error handling
496
605
 
497
606
  ```go
@@ -79,7 +79,7 @@ from .v2_types import (
79
79
  )
80
80
 
81
81
  __author__ = "Memcode Team"
82
- __version__ = "2.4.0"
82
+ __version__ = "2.5.0"
83
83
  __license__ = "Apache-2.0"
84
84
 
85
85
  __all__ = [
@@ -18,6 +18,7 @@ from .v2_client import (
18
18
  _parse_search,
19
19
  _required,
20
20
  _result_mode,
21
+ _safe_request_metadata,
21
22
  _scope,
22
23
  _search_mode,
23
24
  _top_k,
@@ -82,7 +83,7 @@ class AsyncMemcodeV2Client:
82
83
  if value is not None:
83
84
  payload[name] = _required(name, value)
84
85
  if metadata is not None:
85
- payload["metadata"] = metadata
86
+ payload["metadata"] = _safe_request_metadata(metadata)
86
87
  if tags is not None:
87
88
  payload["tags"] = list(dict.fromkeys(_required("tag", tag) for tag in tags))
88
89
  env = await self._transport.post(
@@ -42,11 +42,10 @@ from .errors import OAuthError
42
42
 
43
43
  DEFAULT_ISSUER = "https://memory.memcode.in/"
44
44
  DEFAULT_MEMORY_RESOURCE = "https://memory.memcode.in"
45
- DEFAULT_MCP_DELEGATE_ENDPOINT = (
46
- "https://memory.memcode.in/auth/mcp/oauth/delegate"
47
- )
45
+ DEFAULT_MCP_DELEGATE_ENDPOINT = "https://memory.memcode.in/auth/mcp/oauth/delegate"
48
46
  DEFAULT_SCOPES = ("memory:read", "memory:write")
49
47
  _PKCE_VERIFIER_RE = re.compile(r"^[A-Za-z0-9._~-]{43,128}$")
48
+ _SOFTWARE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
50
49
 
51
50
 
52
51
  @runtime_checkable
@@ -168,7 +167,15 @@ class OAuthClientRegistration:
168
167
  client_name: Optional[str] = None
169
168
  redirect_uris: Tuple[str, ...] = ()
170
169
  scope: Tuple[str, ...] = ()
170
+ software_id: Optional[str] = None
171
+ software_version: Optional[str] = None
171
172
  raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
173
+ # Read-only server attribution. These values are never accepted as DCR
174
+ # inputs and may be absent on older Memcode deployments.
175
+ integration_id: Optional[str] = None
176
+ integration_channel: Optional[str] = None
177
+ attribution_status: Optional[str] = None
178
+ attribution_basis: Optional[str] = None
172
179
 
173
180
 
174
181
  @dataclass(frozen=True)
@@ -187,7 +194,9 @@ class PKCEAuthorizationRequest:
187
194
  def _normalize_scopes(scopes: Sequence[str]) -> Tuple[str, ...]:
188
195
  if isinstance(scopes, (str, bytes)):
189
196
  raise TypeError("OAuth scopes must be a sequence of scope strings")
190
- normalized = tuple(dict.fromkeys(scope.strip() for scope in scopes if scope.strip()))
197
+ normalized = tuple(
198
+ dict.fromkeys(scope.strip() for scope in scopes if scope.strip())
199
+ )
191
200
  if not normalized:
192
201
  raise ValueError("At least one OAuth scope is required")
193
202
  return normalized
@@ -225,7 +234,9 @@ def _oauth_error_from_response(response: httpx.Response) -> OAuthError:
225
234
  payload = {}
226
235
  error = payload.get("error")
227
236
  description = payload.get("error_description") or payload.get("detail")
228
- message = str(description or error or f"OAuth request failed with HTTP {response.status_code}")
237
+ message = str(
238
+ description or error or f"OAuth request failed with HTTP {response.status_code}"
239
+ )
229
240
  return OAuthError(
230
241
  message,
231
242
  error=str(error) if error else None,
@@ -296,9 +307,7 @@ class AsyncMemcodeOAuthClient(AsyncAccessTokenProvider):
296
307
  self._refresh_task_guard = asyncio.Lock()
297
308
  self._refresh_task: Optional[asyncio.Task[str]] = None
298
309
  self._registration_task_guard = asyncio.Lock()
299
- self._registration_task: Optional[
300
- asyncio.Task[OAuthClientRegistration]
301
- ] = None
310
+ self._registration_task: Optional[asyncio.Task[OAuthClientRegistration]] = None
302
311
  self._registration_fingerprint: Optional[Tuple[Any, ...]] = None
303
312
  self._client_registration: Optional[OAuthClientRegistration] = None
304
313
  self._lifecycle_tasks: "set[asyncio.Task[Any]]" = set()
@@ -443,24 +452,43 @@ class AsyncMemcodeOAuthClient(AsyncAccessTokenProvider):
443
452
  client_name: str = "Memcode Python client",
444
453
  application_type: str = "web",
445
454
  scopes: Optional[Sequence[str]] = None,
455
+ software_id: Optional[str] = None,
456
+ software_version: Optional[str] = None,
446
457
  ) -> OAuthClientRegistration:
447
- """Dynamically register a public Authorization Code client."""
458
+ """Dynamically register a public Authorization Code client.
459
+
460
+ ``software_id`` is a public package identifier, not a secret or proof
461
+ of authenticity. Memcode may use a registered value for unverified
462
+ integration analytics while keeping attribution server-owned.
463
+ """
448
464
  metadata = await self.discover()
449
465
  if not metadata.registration_endpoint:
450
466
  raise OAuthError("Authorization server does not advertise DCR")
451
467
  if isinstance(redirect_uris, (str, bytes)):
452
468
  raise TypeError("redirect_uris must be a sequence of URI strings")
453
- normalized_redirects = tuple(dict.fromkeys(uri.strip() for uri in redirect_uris))
469
+ normalized_redirects = tuple(
470
+ dict.fromkeys(uri.strip() for uri in redirect_uris)
471
+ )
454
472
  if not normalized_redirects or any(not uri for uri in normalized_redirects):
455
473
  raise ValueError("At least one non-empty redirect URI is required")
456
474
  if application_type not in {"native", "web"}:
457
475
  raise ValueError("application_type must be native or web")
476
+ normalized_software_id = (software_id or "").strip().lower() or None
477
+ if normalized_software_id and not _SOFTWARE_ID_RE.fullmatch(
478
+ normalized_software_id
479
+ ):
480
+ raise ValueError("software_id must be a canonical public identifier")
481
+ normalized_software_version = (software_version or "").strip() or None
482
+ if normalized_software_version and len(normalized_software_version) > 64:
483
+ raise ValueError("software_version cannot exceed 64 characters")
458
484
  requested_scopes = _normalize_scopes(scopes or self.scopes)
459
485
  fingerprint: Tuple[Any, ...] = (
460
486
  tuple(sorted(normalized_redirects)),
461
487
  client_name,
462
488
  application_type,
463
489
  tuple(sorted(requested_scopes)),
490
+ normalized_software_id,
491
+ normalized_software_version,
464
492
  )
465
493
  async with self._registration_task_guard:
466
494
  if (
@@ -482,6 +510,8 @@ class AsyncMemcodeOAuthClient(AsyncAccessTokenProvider):
482
510
  client_name=client_name,
483
511
  application_type=application_type,
484
512
  requested_scopes=requested_scopes,
513
+ software_id=normalized_software_id,
514
+ software_version=normalized_software_version,
485
515
  fingerprint=fingerprint,
486
516
  ),
487
517
  name="memcode-oauth-client-registration",
@@ -498,20 +528,27 @@ class AsyncMemcodeOAuthClient(AsyncAccessTokenProvider):
498
528
  client_name: str,
499
529
  application_type: str,
500
530
  requested_scopes: Tuple[str, ...],
531
+ software_id: Optional[str],
532
+ software_version: Optional[str],
501
533
  fingerprint: Tuple[Any, ...],
502
534
  ) -> OAuthClientRegistration:
535
+ registration_payload: Dict[str, Any] = {
536
+ "client_name": client_name,
537
+ "redirect_uris": list(redirect_uris),
538
+ "token_endpoint_auth_method": "none",
539
+ "grant_types": ["authorization_code", "refresh_token"],
540
+ "response_types": ["code"],
541
+ "scope": " ".join(requested_scopes),
542
+ "application_type": application_type,
543
+ }
544
+ if software_id:
545
+ registration_payload["software_id"] = software_id
546
+ if software_version:
547
+ registration_payload["software_version"] = software_version
503
548
  payload = await self._request_json(
504
549
  "POST",
505
550
  registration_endpoint,
506
- json={
507
- "client_name": client_name,
508
- "redirect_uris": list(redirect_uris),
509
- "token_endpoint_auth_method": "none",
510
- "grant_types": ["authorization_code", "refresh_token"],
511
- "response_types": ["code"],
512
- "scope": " ".join(requested_scopes),
513
- "application_type": application_type,
514
- },
551
+ json=registration_payload,
515
552
  )
516
553
  client_id = str(payload.get("client_id") or "")
517
554
  if not client_id:
@@ -526,10 +563,37 @@ class AsyncMemcodeOAuthClient(AsyncAccessTokenProvider):
526
563
  client_name=(
527
564
  str(payload["client_name"]) if payload.get("client_name") else None
528
565
  ),
529
- redirect_uris=tuple(payload.get("redirect_uris") or normalized_redirects),
530
- scope=(
531
- tuple(str(payload.get("scope") or "").split())
532
- or requested_scopes
566
+ redirect_uris=tuple(payload.get("redirect_uris") or redirect_uris),
567
+ scope=(tuple(str(payload.get("scope") or "").split()) or requested_scopes),
568
+ software_id=(
569
+ str(payload["software_id"])
570
+ if payload.get("software_id") is not None
571
+ else software_id
572
+ ),
573
+ software_version=(
574
+ str(payload["software_version"])
575
+ if payload.get("software_version") is not None
576
+ else software_version
577
+ ),
578
+ integration_id=(
579
+ str(payload["integration_id"])
580
+ if payload.get("integration_id") is not None
581
+ else None
582
+ ),
583
+ integration_channel=(
584
+ str(payload["integration_channel"])
585
+ if payload.get("integration_channel") is not None
586
+ else None
587
+ ),
588
+ attribution_status=(
589
+ str(payload["attribution_status"])
590
+ if payload.get("attribution_status") is not None
591
+ else None
592
+ ),
593
+ attribution_basis=(
594
+ str(payload["attribution_basis"])
595
+ if payload.get("attribution_basis") is not None
596
+ else None
533
597
  ),
534
598
  raw=dict(payload),
535
599
  )
@@ -654,11 +718,7 @@ class AsyncMemcodeOAuthClient(AsyncAccessTokenProvider):
654
718
  if token_type.lower() != "bearer":
655
719
  raise OAuthError("Memcode requires Bearer access tokens")
656
720
  raw_scope = payload.get("scope")
657
- scopes = (
658
- tuple(str(raw_scope).split())
659
- if raw_scope
660
- else tuple(fallback_scope)
661
- )
721
+ scopes = tuple(str(raw_scope).split()) if raw_scope else tuple(fallback_scope)
662
722
  resource = str(payload.get("resource") or self.resource)
663
723
  if resource.rstrip("/") != self.resource.rstrip("/"):
664
724
  raise OAuthError("Token response resource mismatch")
@@ -27,6 +27,12 @@ from .v2_types import (
27
27
  _READ_SCOPES = {"inherited", "context_only"}
28
28
  _SEARCH_MODES = {"default", "global"}
29
29
  _RESULT_MODES = {"default", "chunks", "memories"}
30
+ _RESERVED_ATTRIBUTION_METADATA = {
31
+ "integration_id",
32
+ "integration_channel",
33
+ "attribution_status",
34
+ "attribution_basis",
35
+ }
30
36
 
31
37
 
32
38
  def _required(name: str, value: str) -> str:
@@ -75,6 +81,16 @@ def _result_mode(value: SearchResultMode) -> SearchResultMode:
75
81
  return value
76
82
 
77
83
 
84
+ def _safe_request_metadata(metadata: Dict[str, Any]) -> Dict[str, Any]:
85
+ reserved = _RESERVED_ATTRIBUTION_METADATA.intersection(metadata)
86
+ if reserved:
87
+ fields = ", ".join(sorted(reserved))
88
+ raise ValueError(
89
+ f"MemcodeV2Client: metadata fields are server-reserved: {fields}"
90
+ )
91
+ return metadata
92
+
93
+
78
94
  def _metadata(data: Dict[str, Any], request_id: Optional[str], elapsed_ms: Optional[float]) -> Dict[str, Any]:
79
95
  return {**data, "request_id": request_id, "elapsed_ms": elapsed_ms}
80
96
 
@@ -214,7 +230,7 @@ class MemcodeV2Client:
214
230
  if value is not None:
215
231
  payload[name] = _required(name, value)
216
232
  if metadata is not None:
217
- payload["metadata"] = metadata
233
+ payload["metadata"] = _safe_request_metadata(metadata)
218
234
  if tags is not None:
219
235
  payload["tags"] = list(dict.fromkeys(_required("tag", tag) for tag in tags))
220
236
  env = self._transport.post(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memcode-sdk
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Python SDK for the Memcode long-term memory API
5
5
  Author: Memcode
6
6
  License-Expression: Apache-2.0
@@ -31,7 +31,7 @@ Available in Python, TypeScript, and Go.
31
31
  All three SDKs share the same design principles:
32
32
 
33
33
  - Existing v1 clients keep three 1:1 methods: **ingest**, **retrieve**, **search**
34
- - Bearer-token authentication via constructor arg or `MEMCODE_API_KEY` env var
34
+ - Bearer-token authentication via a static key or refreshable token provider
35
35
  - Typed error hierarchy so callers can handle auth, rate-limit, and server errors distinctly
36
36
  - Zero config defaults — point at `localhost:8000` with no key and it just works in dev
37
37
 
@@ -233,9 +233,26 @@ registration = await oauth.register_client(
233
233
  client_name="My Pipecat agent",
234
234
  redirect_uris=["https://voice.example.com/oauth/callback"],
235
235
  application_type="web",
236
+ software_id="ai.pipecat.memcode",
237
+ software_version="1.0.0",
236
238
  )
237
239
  ```
238
240
 
241
+ `software_id` is a stable public package identifier, not a credential. If it is
242
+ registered by Memcode, loopback or otherwise unverified callbacks can be counted
243
+ under that integration with `attribution_status="unverified"` and
244
+ `attribution_basis="dcr_software_id"`. An exact registered HTTPS callback is
245
+ still required for verified SDK attribution. Conflicting callback and software
246
+ owners are rejected by the authorization server.
247
+
248
+ Memcode may return read-only `integration_id`, `integration_channel`,
249
+ `attribution_status`, and `attribution_basis` values on the registration.
250
+ They are assigned by the server from its trusted integration registry. The SDK
251
+ does not send these values during registration or on Memory API requests, so
252
+ applications need no attribution parameter or additional attribution secret.
253
+ Older servers may omit all four values. Tenant-bound v2 requests reject these
254
+ server-owned field names when supplied in request metadata.
255
+
239
256
  The helper uses Authorization Code with S256 PKCE, resource indicators,
240
257
  rotating refresh tokens, and single-flight refresh per token-store key. A 401
241
258
  causes at most one refresh and one request retry.
@@ -378,6 +395,47 @@ const advancedAnswer = await client.retrieveV2({
378
395
  });
379
396
  ```
380
397
 
398
+ ### OAuth and dynamic bearer tokens
399
+
400
+ TypeScript 2.5 adds `MemcodeOAuthClient`, `OAuthTokenStore`, and
401
+ `DelegatingMemoryTokenProvider`. The flow uses dynamic client registration,
402
+ Authorization Code with S256 PKCE, resource indicators, rotating refresh
403
+ tokens, and revocation:
404
+
405
+ ```typescript
406
+ import { MemcodeClient, MemcodeOAuthClient } from "memcode-sdk";
407
+
408
+ const oauth = new MemcodeOAuthClient({
409
+ issuer: "https://memory.memcode.in/",
410
+ resource: "https://memory.memcode.in",
411
+ clientId: persistedDynamicClientId,
412
+ tokenKey: `voice:${applicationUserId}`,
413
+ tokenStore: encryptedDistributedTokenStore,
414
+ });
415
+
416
+ const request = await oauth.createAuthorizationRequest({
417
+ redirectUri: "https://voice.example.com/oauth/callback",
418
+ });
419
+ await oauth.exchangeCode({
420
+ code: callbackCode,
421
+ returnedState: callbackState,
422
+ authorizationRequest: request,
423
+ });
424
+
425
+ const oauthClient = new MemcodeClient("https://memory.memcode.in", oauth);
426
+ ```
427
+
428
+ Dynamic-registration responses may expose the server-assigned, read-only
429
+ `integrationId`, `integrationChannel`, `attributionStatus`, and
430
+ `attributionBasis` fields. The SDK never sends those values in registration or
431
+ Memory API requests; no integration-supplied attribution parameter or new
432
+ secret is required. All fields remain optional for older servers, and the same
433
+ server-owned names are rejected in tenant-bound v2 request metadata.
434
+
435
+ The legacy function-style `MemcodeV2Client.accessTokenProvider` remains
436
+ supported. A refreshable object provider gets at most one refresh and one
437
+ request retry after a 401.
438
+
381
439
  ### Error handling
382
440
 
383
441
  ```typescript
@@ -515,6 +573,57 @@ func main() {
515
573
  }
516
574
  ```
517
575
 
576
+ ### OAuth and dynamic bearer tokens
577
+
578
+ Go 2.4 adds `OAuthClient`, `OAuthTokenStore`, and
579
+ `DelegatingMemoryTokenProvider`. OAuth operations take `context.Context`; the
580
+ API clients resolve a token per request and perform at most one refresh and one
581
+ retry after a 401.
582
+
583
+ ```go
584
+ store := encryptedDistributedTokenStore
585
+ oauth, err := memcode.NewOAuthClient(memcode.OAuthClientOptions{
586
+ Issuer: "https://memory.memcode.in/",
587
+ Resource: "https://memory.memcode.in",
588
+ ClientID: persistedDynamicClientID,
589
+ TokenKey: "voice:" + applicationUserID,
590
+ TokenStore: store,
591
+ })
592
+ if err != nil {
593
+ panic(err)
594
+ }
595
+
596
+ request, err := oauth.CreateAuthorizationRequest(ctx,
597
+ memcode.CreateAuthorizationRequestOptions{
598
+ RedirectURI: "https://voice.example.com/oauth/callback",
599
+ })
600
+ // Redirect to request.AuthorizationURL and retain request server-side.
601
+ tokens, err := oauth.ExchangeCode(ctx, callbackCode, callbackState, request)
602
+ _ = tokens
603
+
604
+ client, err := memcode.NewClientWithAccessTokenProvider(
605
+ "https://memory.memcode.in",
606
+ oauth,
607
+ )
608
+ ```
609
+
610
+ `RegisterOAuthClientOptions` also accepts `SoftwareID` and `SoftwareVersion`.
611
+ `SoftwareID` is a public analytics identifier, not a credential; when it is the
612
+ only registry match, the server returns unverified `dcr_software_id`
613
+ attribution. An exact registered HTTPS callback is still required for verified
614
+ SDK attribution.
615
+
616
+ `OAuthClientRegistration` may contain read-only `IntegrationID`,
617
+ `IntegrationChannel`, `AttributionStatus`, and `AttributionBasis` values
618
+ assigned by the server. They are not DCR inputs and are never copied to Memory
619
+ API requests, so callers need no attribution parameter or additional secret.
620
+ The fields are empty when an older server omits them. Tenant-bound v2 ingest
621
+ rejects the same server-owned names in request metadata.
622
+
623
+ The supplied store must encrypt tokens, atomically replace rotated token sets,
624
+ and implement `WithRefreshLease` as a distributed lease when multiple workers
625
+ share a grant. `NewInMemoryOAuthTokenStore` is only for tests and examples.
626
+
518
627
  ### Error handling
519
628
 
520
629
  ```go
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memcode-sdk"
7
- version = "2.4.0"
7
+ version = "2.5.0"
8
8
  description = "Python SDK for the Memcode long-term memory API"
9
9
  readme = { file = "README.MD", content-type = "text/markdown" }
10
10
  requires-python = ">=3.9"
@@ -74,9 +74,11 @@ class AsyncTransportAuthTests(unittest.IsolatedAsyncioTestCase):
74
74
  async def test_provider_is_resolved_per_request_and_retries_one_401(self):
75
75
  provider = FakeTokenProvider()
76
76
  authorizations = []
77
+ request_headers = []
77
78
 
78
79
  async def handler(request):
79
80
  authorizations.append(request.headers.get("Authorization"))
81
+ request_headers.append({name.lower() for name in request.headers})
80
82
  if len(authorizations) == 1:
81
83
  return httpx.Response(401, json={"error": "expired"})
82
84
  return httpx.Response(200, json=envelope({"results": [], "total": 0}))
@@ -103,6 +105,10 @@ class AsyncTransportAuthTests(unittest.IsolatedAsyncioTestCase):
103
105
  )
104
106
  self.assertEqual(provider.get_calls, 2)
105
107
  self.assertEqual(provider.refresh_calls, 1)
108
+ self.assertTrue(all(
109
+ not any("integration" in name or "attribution" in name for name in headers)
110
+ for headers in request_headers
111
+ ))
106
112
 
107
113
  async def test_second_401_is_returned_without_a_third_attempt(self):
108
114
  provider = FakeTokenProvider()
@@ -281,6 +287,10 @@ class OAuthLifecycleCancellationTests(unittest.IsolatedAsyncioTestCase):
281
287
  allow_registration_response.set()
282
288
  registration = await asyncio.wait_for(retry, timeout=1)
283
289
  self.assertEqual(registration.client_id, "client-recovered")
290
+ self.assertIsNone(registration.integration_id)
291
+ self.assertIsNone(registration.integration_channel)
292
+ self.assertIsNone(registration.attribution_status)
293
+ self.assertIsNone(registration.attribution_basis)
284
294
  self.assertEqual(oauth.client_id, "client-recovered")
285
295
  cached = await oauth.register_client(
286
296
  client_name="Pipecat Memcode",
@@ -326,6 +336,10 @@ class OAuthClientTests(unittest.IsolatedAsyncioTestCase):
326
336
  return httpx.Response(201, json={
327
337
  "client_id": "client-1",
328
338
  "client_id_issued_at": 1_700_000_000,
339
+ "integration_id": "voice_partner",
340
+ "integration_channel": "sdk",
341
+ "attribution_status": "verified",
342
+ "attribution_basis": "verified_redirect",
329
343
  **body,
330
344
  })
331
345
  if request.url.path == "/oauth/token":
@@ -375,6 +389,45 @@ class OAuthClientTests(unittest.IsolatedAsyncioTestCase):
375
389
  await self.oauth.close()
376
390
  await self.http.aclose()
377
391
 
392
+ async def test_dcr_attribution_response_fields_are_optional(self):
393
+ async def handler(request):
394
+ if request.url.path == "/.well-known/oauth-authorization-server":
395
+ return httpx.Response(200, json={
396
+ "issuer": "https://memory.example.com/",
397
+ "authorization_endpoint": "https://app.example.com/oauth/authorize",
398
+ "token_endpoint": "https://memory.example.com/oauth/token",
399
+ "registration_endpoint": "https://memory.example.com/oauth/register",
400
+ "code_challenge_methods_supported": ["S256"],
401
+ })
402
+ if request.url.path == "/oauth/register":
403
+ return httpx.Response(201, json={"client_id": "legacy-client"})
404
+ return httpx.Response(404)
405
+
406
+ http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
407
+ oauth = AsyncMemcodeOAuthClient(
408
+ issuer="https://memory.example.com/",
409
+ resource="https://memory.example.com",
410
+ token_key="legacy-registration",
411
+ http_client=http,
412
+ )
413
+ try:
414
+ registration = await oauth.register_client(
415
+ redirect_uris=("https://voice.example.com/oauth/callback",),
416
+ )
417
+ finally:
418
+ await oauth.close()
419
+ await http.aclose()
420
+
421
+ self.assertEqual(registration.client_id, "legacy-client")
422
+ self.assertEqual(
423
+ registration.redirect_uris,
424
+ ("https://voice.example.com/oauth/callback",),
425
+ )
426
+ self.assertIsNone(registration.integration_id)
427
+ self.assertIsNone(registration.integration_channel)
428
+ self.assertIsNone(registration.attribution_status)
429
+ self.assertIsNone(registration.attribution_basis)
430
+
378
431
  async def test_dcr_pkce_exchange_singleflight_refresh_and_revocation(self):
379
432
  resource_metadata = await self.oauth.discover_protected_resource()
380
433
  self.assertEqual(
@@ -385,8 +438,36 @@ class OAuthClientTests(unittest.IsolatedAsyncioTestCase):
385
438
  registration = await self.oauth.register_client(
386
439
  client_name="Pipecat Memcode",
387
440
  redirect_uris=("https://voice.example.com/oauth/callback",),
441
+ software_id="ai.pipecat.memcode",
442
+ software_version="1.2.3",
388
443
  )
389
444
  self.assertEqual(registration.client_id, "client-1")
445
+ self.assertEqual(registration.software_id, "ai.pipecat.memcode")
446
+ self.assertEqual(registration.software_version, "1.2.3")
447
+ self.assertEqual(registration.integration_id, "voice_partner")
448
+ self.assertEqual(registration.integration_channel, "sdk")
449
+ self.assertEqual(registration.attribution_status, "verified")
450
+ self.assertEqual(registration.attribution_basis, "verified_redirect")
451
+
452
+ registration_request = next(
453
+ request for request in self.requests
454
+ if request.url.path == "/oauth/register"
455
+ )
456
+ registration_payload = json.loads(registration_request.content)
457
+ self.assertEqual(registration_payload["software_id"], "ai.pipecat.memcode")
458
+ self.assertEqual(registration_payload["software_version"], "1.2.3")
459
+ self.assertFalse({
460
+ "integration_id",
461
+ "integration_channel",
462
+ "attribution_status",
463
+ "attribution_basis",
464
+ } & registration_payload.keys())
465
+
466
+ with self.assertRaisesRegex(ValueError, "canonical public identifier"):
467
+ await self.oauth.register_client(
468
+ redirect_uris=("https://voice.example.com/oauth/callback",),
469
+ software_id="Attacker supplied identity",
470
+ )
390
471
 
391
472
  self.authorization = await self.oauth.create_authorization_request(
392
473
  redirect_uri="https://voice.example.com/oauth/callback",
@@ -114,6 +114,24 @@ class V2ClientTests(unittest.TestCase):
114
114
  self.assertNotIn("user_id", body)
115
115
  self.assertEqual(result.status, "processing")
116
116
 
117
+ def test_ingest_rejects_server_attribution_metadata(self):
118
+ for field in (
119
+ "integration_id",
120
+ "integration_channel",
121
+ "attribution_status",
122
+ "attribution_basis",
123
+ ):
124
+ with self.subTest(field=field):
125
+ before = len(self.requests)
126
+ with self.assertRaisesRegex(ValueError, "server-reserved"):
127
+ self.client.ingest(
128
+ space_id="space-1",
129
+ content="Launch is Friday",
130
+ idempotency_key=f"turn-{field}",
131
+ metadata={field: "spoofed"},
132
+ )
133
+ self.assertEqual(len(self.requests), before)
134
+
117
135
  def test_search_preserves_scope_mode_and_partial_metadata(self):
118
136
  result = self.client.search(
119
137
  context_space_id="space-1",
@@ -196,6 +214,35 @@ class V2ClientTests(unittest.TestCase):
196
214
 
197
215
 
198
216
  class AsyncV2ClientTests(unittest.IsolatedAsyncioTestCase):
217
+ async def test_async_ingest_rejects_server_attribution_metadata(self):
218
+ request_count = 0
219
+
220
+ async def handler(_request: httpx.Request) -> httpx.Response:
221
+ nonlocal request_count
222
+ request_count += 1
223
+ return httpx.Response(500)
224
+
225
+ client = AsyncMemcodeV2Client(
226
+ api_url="https://memory.example.com", api_key="secret",
227
+ )
228
+ await client._transport._client.aclose()
229
+ client._transport._client = httpx.AsyncClient(
230
+ base_url="https://memory.example.com",
231
+ transport=httpx.MockTransport(handler),
232
+ )
233
+ try:
234
+ with self.assertRaisesRegex(ValueError, "server-reserved"):
235
+ await client.ingest(
236
+ space_id="space-1",
237
+ content="Launch is Friday",
238
+ idempotency_key="turn-1",
239
+ metadata={"integration_id": "spoofed"},
240
+ )
241
+ finally:
242
+ await client.close()
243
+
244
+ self.assertEqual(request_count, 0)
245
+
199
246
  async def test_async_memory_graph_supports_nodes_only_page(self):
200
247
  captured = {}
201
248
 
File without changes