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.
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/PKG-INFO +111 -2
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/README.MD +110 -1
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/__init__.py +1 -1
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/async_v2_client.py +2 -1
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/oauth.py +88 -28
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/v2_client.py +17 -1
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/PKG-INFO +111 -2
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/pyproject.toml +1 -1
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/tests/test_oauth.py +81 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/tests/test_v2_clients.py +47 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/_http.py +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/async_client.py +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/client.py +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/errors.py +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/py.typed +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/types.py +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk/v2_types.py +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/SOURCES.txt +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/dependency_links.txt +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/requires.txt +0 -0
- {memcode_sdk-2.4.0 → memcode_sdk-2.5.0}/memcode_sdk.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
530
|
-
scope=(
|
|
531
|
-
|
|
532
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|