copass-core 0.1.0__py3-none-any.whl
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.
- copass_core/__init__.py +99 -0
- copass_core/auth/__init__.py +12 -0
- copass_core/auth/api_key.py +27 -0
- copass_core/auth/bearer.py +36 -0
- copass_core/auth/types.py +47 -0
- copass_core/client.py +119 -0
- copass_core/http/__init__.py +31 -0
- copass_core/http/errors.py +60 -0
- copass_core/http/http_client.py +171 -0
- copass_core/http/retry.py +77 -0
- copass_core/resources/__init__.py +12 -0
- copass_core/resources/base.py +78 -0
- copass_core/resources/context.py +74 -0
- copass_core/resources/retrieval.py +147 -0
- copass_core/types.py +77 -0
- copass_core-0.1.0.dist-info/METADATA +102 -0
- copass_core-0.1.0.dist-info/RECORD +18 -0
- copass_core-0.1.0.dist-info/WHEEL +4 -0
copass_core/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Copass Python client SDK.
|
|
2
|
+
|
|
3
|
+
Python mirror of `@copass/core`_. v0.1.0 scope:
|
|
4
|
+
|
|
5
|
+
- ``CopassClient`` top-level entry point
|
|
6
|
+
- Auth providers: ``ApiKeyAuthProvider``, ``BearerAuthProvider``
|
|
7
|
+
- ``HttpClient`` with retry + middleware
|
|
8
|
+
- Retrieval resource: ``discover`` / ``interpret`` / ``search``
|
|
9
|
+
- Context resource: ``/context/for-agent/{minimal,adaptive,comprehensive}``
|
|
10
|
+
|
|
11
|
+
Deferred to a future release (``v0.2+``): crypto primitives + Supabase
|
|
12
|
+
auth provider, ContextWindow / SourcesResource / IngestResource,
|
|
13
|
+
full resource coverage (matrix, sandboxes, projects, entities, vault,
|
|
14
|
+
usage, api-keys, users).
|
|
15
|
+
|
|
16
|
+
.. _`@copass/core`: https://github.com/olane-labs/copass-harness/tree/main/typescript/packages/core
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from copass_core.auth import (
|
|
20
|
+
ApiKeyAuthProvider,
|
|
21
|
+
AuthProvider,
|
|
22
|
+
BearerAuthProvider,
|
|
23
|
+
SessionContext,
|
|
24
|
+
)
|
|
25
|
+
from copass_core.client import (
|
|
26
|
+
DEFAULT_API_URL,
|
|
27
|
+
ApiKeyAuth,
|
|
28
|
+
AuthConfig,
|
|
29
|
+
BearerAuth,
|
|
30
|
+
CopassClient,
|
|
31
|
+
ProviderAuth,
|
|
32
|
+
)
|
|
33
|
+
from copass_core.http import (
|
|
34
|
+
CopassApiError,
|
|
35
|
+
CopassNetworkError,
|
|
36
|
+
CopassValidationError,
|
|
37
|
+
HttpClient,
|
|
38
|
+
HttpClientOptions,
|
|
39
|
+
RequestContext,
|
|
40
|
+
RequestMiddleware,
|
|
41
|
+
RequestOptions,
|
|
42
|
+
ResponseContext,
|
|
43
|
+
ResponseMiddleware,
|
|
44
|
+
retry_with_backoff,
|
|
45
|
+
)
|
|
46
|
+
from copass_core.resources import (
|
|
47
|
+
BaseResource,
|
|
48
|
+
ContextResource,
|
|
49
|
+
ContextTier,
|
|
50
|
+
RetrievalResource,
|
|
51
|
+
)
|
|
52
|
+
from copass_core.types import (
|
|
53
|
+
ChatMessage,
|
|
54
|
+
ChatRole,
|
|
55
|
+
RetryConfig,
|
|
56
|
+
SearchPreset,
|
|
57
|
+
WindowLike,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__version__ = "0.1.0"
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"__version__",
|
|
64
|
+
# Client
|
|
65
|
+
"CopassClient",
|
|
66
|
+
"AuthConfig",
|
|
67
|
+
"ApiKeyAuth",
|
|
68
|
+
"BearerAuth",
|
|
69
|
+
"ProviderAuth",
|
|
70
|
+
"DEFAULT_API_URL",
|
|
71
|
+
# Auth
|
|
72
|
+
"AuthProvider",
|
|
73
|
+
"SessionContext",
|
|
74
|
+
"ApiKeyAuthProvider",
|
|
75
|
+
"BearerAuthProvider",
|
|
76
|
+
# HTTP
|
|
77
|
+
"HttpClient",
|
|
78
|
+
"HttpClientOptions",
|
|
79
|
+
"RequestOptions",
|
|
80
|
+
"RequestContext",
|
|
81
|
+
"ResponseContext",
|
|
82
|
+
"RequestMiddleware",
|
|
83
|
+
"ResponseMiddleware",
|
|
84
|
+
"CopassApiError",
|
|
85
|
+
"CopassNetworkError",
|
|
86
|
+
"CopassValidationError",
|
|
87
|
+
"retry_with_backoff",
|
|
88
|
+
# Resources
|
|
89
|
+
"BaseResource",
|
|
90
|
+
"RetrievalResource",
|
|
91
|
+
"ContextResource",
|
|
92
|
+
"ContextTier",
|
|
93
|
+
# Types
|
|
94
|
+
"RetryConfig",
|
|
95
|
+
"ChatMessage",
|
|
96
|
+
"ChatRole",
|
|
97
|
+
"WindowLike",
|
|
98
|
+
"SearchPreset",
|
|
99
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Authentication providers."""
|
|
2
|
+
|
|
3
|
+
from copass_core.auth.api_key import ApiKeyAuthProvider
|
|
4
|
+
from copass_core.auth.bearer import BearerAuthProvider
|
|
5
|
+
from copass_core.auth.types import AuthProvider, SessionContext
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AuthProvider",
|
|
9
|
+
"SessionContext",
|
|
10
|
+
"ApiKeyAuthProvider",
|
|
11
|
+
"BearerAuthProvider",
|
|
12
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""API key auth provider.
|
|
2
|
+
|
|
3
|
+
Hand-ported from ``typescript/packages/core/src/auth/api-key.ts``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from copass_core.auth.types import AuthProvider, SessionContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiKeyAuthProvider:
|
|
12
|
+
"""Sends a long-lived API key (``olk_...``) as a bearer token.
|
|
13
|
+
|
|
14
|
+
No session token; API keys don't support DEK wrapping in this
|
|
15
|
+
release.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, key: str) -> None:
|
|
19
|
+
if not key:
|
|
20
|
+
raise ValueError("ApiKeyAuthProvider requires a non-empty key")
|
|
21
|
+
self._key = key
|
|
22
|
+
|
|
23
|
+
async def get_session(self) -> SessionContext:
|
|
24
|
+
return SessionContext(access_token=self._key)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["ApiKeyAuthProvider"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Bearer JWT auth provider.
|
|
2
|
+
|
|
3
|
+
Hand-ported from ``typescript/packages/core/src/auth/bearer.ts``. In
|
|
4
|
+
v0.1.0 the encryption-key path is deferred — the provider simply
|
|
5
|
+
forwards the caller-supplied JWT. A later release will add
|
|
6
|
+
``createSessionToken`` integration once the crypto module lands.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from copass_core.auth.types import AuthProvider, SessionContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BearerAuthProvider:
|
|
17
|
+
"""Forwards a caller-managed JWT as the bearer token.
|
|
18
|
+
|
|
19
|
+
The caller is responsible for refreshing the JWT when it expires —
|
|
20
|
+
mint a new provider with the new token or wrap your own refresh
|
|
21
|
+
logic in a custom :class:`AuthProvider` implementation.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, token: str, encryption_key: Optional[str] = None) -> None:
|
|
25
|
+
if not token:
|
|
26
|
+
raise ValueError("BearerAuthProvider requires a non-empty token")
|
|
27
|
+
self._token = token
|
|
28
|
+
# Stored but unused in v0.1.0 — reserved for the forthcoming
|
|
29
|
+
# crypto module (session-token derivation for vault access).
|
|
30
|
+
self._encryption_key = encryption_key
|
|
31
|
+
|
|
32
|
+
async def get_session(self) -> SessionContext:
|
|
33
|
+
return SessionContext(access_token=self._token)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["BearerAuthProvider"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Authentication interfaces.
|
|
2
|
+
|
|
3
|
+
Hand-ported from ``typescript/packages/core/src/auth/types.ts``. The
|
|
4
|
+
Python port drops the session-token / encryption fields that aren't
|
|
5
|
+
needed by v0.1.0 consumers; add them back when the crypto module
|
|
6
|
+
lands in a later release.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SessionContext:
|
|
17
|
+
"""Active session context with resolved credentials.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
access_token: The JWT or API key for the ``Authorization``
|
|
21
|
+
header.
|
|
22
|
+
session_token: Optional wrapped DEK for the
|
|
23
|
+
``X-Encryption-Token`` header. ``None`` in v0.1.0 — the
|
|
24
|
+
crypto module is deferred.
|
|
25
|
+
user_id: User id extracted from the token. ``None`` for API
|
|
26
|
+
keys (not decoded).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
access_token: str
|
|
30
|
+
session_token: Optional[str] = None
|
|
31
|
+
user_id: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class AuthProvider(Protocol):
|
|
36
|
+
"""Structural contract for authentication providers.
|
|
37
|
+
|
|
38
|
+
Each auth strategy (API key, bearer JWT, future Supabase) exposes
|
|
39
|
+
:meth:`get_session`. The HTTP client calls it before each request
|
|
40
|
+
to obtain fresh credentials; providers are free to cache + refresh
|
|
41
|
+
under the hood.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
async def get_session(self) -> SessionContext: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["AuthProvider", "SessionContext"]
|
copass_core/client.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""CopassClient — top-level entry point.
|
|
2
|
+
|
|
3
|
+
Hand-ported from ``typescript/packages/core/src/client.ts``. v0.1.0
|
|
4
|
+
exposes the two resources that v0.1.0 consumers need:
|
|
5
|
+
:class:`RetrievalResource` (``discover`` / ``interpret`` / ``search``)
|
|
6
|
+
and :class:`ContextResource` (``/context/for-agent/*``). Additional
|
|
7
|
+
resources (``sandboxes``, ``sources``, ``ingest``, ``vault``, etc.)
|
|
8
|
+
land incrementally.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import List, Optional, Union
|
|
15
|
+
|
|
16
|
+
from copass_core.auth.api_key import ApiKeyAuthProvider
|
|
17
|
+
from copass_core.auth.bearer import BearerAuthProvider
|
|
18
|
+
from copass_core.auth.types import AuthProvider
|
|
19
|
+
from copass_core.http.http_client import (
|
|
20
|
+
HttpClient,
|
|
21
|
+
HttpClientOptions,
|
|
22
|
+
RequestMiddleware,
|
|
23
|
+
ResponseMiddleware,
|
|
24
|
+
)
|
|
25
|
+
from copass_core.resources.context import ContextResource
|
|
26
|
+
from copass_core.resources.retrieval import RetrievalResource
|
|
27
|
+
from copass_core.types import RetryConfig
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEFAULT_API_URL = "https://ai.copass.id"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ApiKeyAuth:
|
|
35
|
+
"""``auth=ApiKeyAuth(key="olk_...")``."""
|
|
36
|
+
|
|
37
|
+
key: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class BearerAuth:
|
|
42
|
+
"""``auth=BearerAuth(token="eyJ...")``. Caller owns refresh."""
|
|
43
|
+
|
|
44
|
+
token: str
|
|
45
|
+
encryption_key: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ProviderAuth:
|
|
50
|
+
"""``auth=ProviderAuth(provider=MyCustomAuthProvider(...))``."""
|
|
51
|
+
|
|
52
|
+
provider: AuthProvider
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
AuthConfig = Union[ApiKeyAuth, BearerAuth, ProviderAuth]
|
|
56
|
+
"""Discriminated-union of auth configurations. Supabase OTP auth is
|
|
57
|
+
deferred to a later release."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_auth_provider(auth: AuthConfig) -> AuthProvider:
|
|
61
|
+
if isinstance(auth, ApiKeyAuth):
|
|
62
|
+
return ApiKeyAuthProvider(auth.key)
|
|
63
|
+
if isinstance(auth, BearerAuth):
|
|
64
|
+
return BearerAuthProvider(auth.token, auth.encryption_key)
|
|
65
|
+
if isinstance(auth, ProviderAuth):
|
|
66
|
+
return auth.provider
|
|
67
|
+
raise TypeError(f"Unsupported AuthConfig type: {type(auth).__name__}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CopassClient:
|
|
71
|
+
"""Main entry point for the Copass Python SDK.
|
|
72
|
+
|
|
73
|
+
Resources are accessed as instance attributes (Stripe-style).
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
>>> client = CopassClient(auth=ApiKeyAuth(key="olk_..."))
|
|
77
|
+
>>> menu = await client.retrieval.discover(
|
|
78
|
+
... sandbox_id="sb-1",
|
|
79
|
+
... query="How does auth work?",
|
|
80
|
+
... )
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
retrieval: RetrievalResource
|
|
84
|
+
context: ContextResource
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
auth: AuthConfig,
|
|
90
|
+
api_url: str = DEFAULT_API_URL,
|
|
91
|
+
retry: Optional[RetryConfig] = None,
|
|
92
|
+
on_request: Optional[List[RequestMiddleware]] = None,
|
|
93
|
+
on_response: Optional[List[ResponseMiddleware]] = None,
|
|
94
|
+
timeout: float = 30.0,
|
|
95
|
+
) -> None:
|
|
96
|
+
auth_provider = _build_auth_provider(auth)
|
|
97
|
+
http = HttpClient(
|
|
98
|
+
HttpClientOptions(
|
|
99
|
+
api_url=api_url,
|
|
100
|
+
auth_provider=auth_provider,
|
|
101
|
+
retry=retry,
|
|
102
|
+
on_request=list(on_request or []),
|
|
103
|
+
on_response=list(on_response or []),
|
|
104
|
+
timeout=timeout,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
self._http = http
|
|
108
|
+
self.retrieval = RetrievalResource(http)
|
|
109
|
+
self.context = ContextResource(http)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = [
|
|
113
|
+
"CopassClient",
|
|
114
|
+
"AuthConfig",
|
|
115
|
+
"ApiKeyAuth",
|
|
116
|
+
"BearerAuth",
|
|
117
|
+
"ProviderAuth",
|
|
118
|
+
"DEFAULT_API_URL",
|
|
119
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""HTTP client primitives."""
|
|
2
|
+
|
|
3
|
+
from copass_core.http.errors import (
|
|
4
|
+
CopassApiError,
|
|
5
|
+
CopassNetworkError,
|
|
6
|
+
CopassValidationError,
|
|
7
|
+
)
|
|
8
|
+
from copass_core.http.http_client import (
|
|
9
|
+
HttpClient,
|
|
10
|
+
HttpClientOptions,
|
|
11
|
+
RequestContext,
|
|
12
|
+
RequestMiddleware,
|
|
13
|
+
RequestOptions,
|
|
14
|
+
ResponseContext,
|
|
15
|
+
ResponseMiddleware,
|
|
16
|
+
)
|
|
17
|
+
from copass_core.http.retry import retry_with_backoff
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"HttpClient",
|
|
21
|
+
"HttpClientOptions",
|
|
22
|
+
"RequestOptions",
|
|
23
|
+
"RequestContext",
|
|
24
|
+
"ResponseContext",
|
|
25
|
+
"RequestMiddleware",
|
|
26
|
+
"ResponseMiddleware",
|
|
27
|
+
"CopassApiError",
|
|
28
|
+
"CopassNetworkError",
|
|
29
|
+
"CopassValidationError",
|
|
30
|
+
"retry_with_backoff",
|
|
31
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""HTTP error types.
|
|
2
|
+
|
|
3
|
+
Hand-ported from ``typescript/packages/core/src/http/errors.ts``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CopassApiError(Exception):
|
|
12
|
+
"""Raised when the Copass API returns a non-2xx response.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
status: HTTP status code.
|
|
16
|
+
body: Parsed JSON body (if parseable) or raw response text.
|
|
17
|
+
path: Request path that failed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
status: int,
|
|
24
|
+
body: Any = None,
|
|
25
|
+
path: Optional[str] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status = status
|
|
29
|
+
self.body = body
|
|
30
|
+
self.path = path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CopassNetworkError(Exception):
|
|
34
|
+
"""Raised on network-level failures (DNS, timeout, connection
|
|
35
|
+
refused). Caller should retry at a higher level or surface to the
|
|
36
|
+
end user."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.cause = cause
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CopassValidationError(Exception):
|
|
44
|
+
"""Raised for client-side validation failures before the request
|
|
45
|
+
leaves the SDK (e.g., missing required parameter).
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
fields: The field name(s) that failed validation.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, fields: Optional[List[str]] = None) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.fields = fields or []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"CopassApiError",
|
|
58
|
+
"CopassNetworkError",
|
|
59
|
+
"CopassValidationError",
|
|
60
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Internal async HTTP client for the Copass API.
|
|
2
|
+
|
|
3
|
+
Port of ``typescript/packages/core/src/http/http-client.ts`` with the
|
|
4
|
+
v0.1.0 scope trimmed:
|
|
5
|
+
|
|
6
|
+
- No encrypted-payload path (crypto module deferred).
|
|
7
|
+
- No file-upload path (single consumer today is retrieval; can be
|
|
8
|
+
added when ingest lands).
|
|
9
|
+
- Middleware hooks supported — mirror the TS ``onRequest`` /
|
|
10
|
+
``onResponse`` signatures so telemetry-hook patterns transfer.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from copass_core.auth.types import AuthProvider
|
|
23
|
+
from copass_core.http.errors import CopassApiError
|
|
24
|
+
from copass_core.http.retry import retry_with_backoff
|
|
25
|
+
from copass_core.types import RetryConfig
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RequestContext:
|
|
30
|
+
"""Metadata about a request, passed to middleware."""
|
|
31
|
+
|
|
32
|
+
method: str
|
|
33
|
+
path: str
|
|
34
|
+
url: str
|
|
35
|
+
headers: Dict[str, str]
|
|
36
|
+
body: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ResponseContext:
|
|
41
|
+
"""Metadata about a completed response, passed to middleware."""
|
|
42
|
+
|
|
43
|
+
request: RequestContext
|
|
44
|
+
status: int
|
|
45
|
+
duration_ms: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
RequestMiddleware = Callable[[RequestContext], Union[None, Awaitable[None]]]
|
|
49
|
+
"""Middleware invoked before each request. May mutate the
|
|
50
|
+
:class:`RequestContext` (headers, body)."""
|
|
51
|
+
|
|
52
|
+
ResponseMiddleware = Callable[[ResponseContext], Union[None, Awaitable[None]]]
|
|
53
|
+
"""Middleware invoked after each successful response."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class HttpClientOptions:
|
|
58
|
+
api_url: str
|
|
59
|
+
auth_provider: AuthProvider
|
|
60
|
+
retry: Optional[RetryConfig] = None
|
|
61
|
+
on_request: List[RequestMiddleware] = field(default_factory=list)
|
|
62
|
+
on_response: List[ResponseMiddleware] = field(default_factory=list)
|
|
63
|
+
timeout: float = 30.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class RequestOptions:
|
|
68
|
+
method: str = "GET"
|
|
69
|
+
body: Any = None
|
|
70
|
+
query: Optional[Dict[str, Optional[str]]] = None
|
|
71
|
+
headers: Optional[Dict[str, str]] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class HttpClient:
|
|
75
|
+
"""Thin async wrapper around ``httpx.AsyncClient`` that handles
|
|
76
|
+
auth-header injection, retry, error normalization, and middleware.
|
|
77
|
+
|
|
78
|
+
A single client instance is safe to share across resources — each
|
|
79
|
+
:meth:`request` opens its own short-lived ``httpx.AsyncClient`` so
|
|
80
|
+
connection pooling is not attempted at this layer. (Add a shared
|
|
81
|
+
``AsyncClient`` in a later release if latency telemetry shows
|
|
82
|
+
per-request handshake cost matters.)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, options: HttpClientOptions) -> None:
|
|
86
|
+
self._api_url = options.api_url.rstrip("/")
|
|
87
|
+
self._auth_provider = options.auth_provider
|
|
88
|
+
self._retry_config = options.retry
|
|
89
|
+
self._on_request = list(options.on_request or [])
|
|
90
|
+
self._on_response = list(options.on_response or [])
|
|
91
|
+
self._timeout = options.timeout
|
|
92
|
+
|
|
93
|
+
async def request(self, path: str, options: Optional[RequestOptions] = None) -> Any:
|
|
94
|
+
opts = options or RequestOptions()
|
|
95
|
+
session = await self._auth_provider.get_session()
|
|
96
|
+
|
|
97
|
+
headers: Dict[str, str] = {
|
|
98
|
+
"Authorization": f"Bearer {session.access_token}",
|
|
99
|
+
**(opts.headers or {}),
|
|
100
|
+
}
|
|
101
|
+
if "Content-Type" not in headers and "content-type" not in headers:
|
|
102
|
+
headers["Content-Type"] = "application/json"
|
|
103
|
+
if session.session_token:
|
|
104
|
+
headers["X-Encryption-Token"] = session.session_token
|
|
105
|
+
|
|
106
|
+
body_text: Optional[str] = None
|
|
107
|
+
if opts.body is not None:
|
|
108
|
+
body_text = json.dumps(opts.body)
|
|
109
|
+
|
|
110
|
+
url = self._api_url + path
|
|
111
|
+
if opts.query:
|
|
112
|
+
non_null = {k: v for k, v in opts.query.items() if v is not None}
|
|
113
|
+
if non_null:
|
|
114
|
+
url = f"{url}?{httpx.QueryParams(non_null)}"
|
|
115
|
+
|
|
116
|
+
ctx = RequestContext(
|
|
117
|
+
method=opts.method,
|
|
118
|
+
path=path,
|
|
119
|
+
url=url,
|
|
120
|
+
headers=headers,
|
|
121
|
+
body=body_text,
|
|
122
|
+
)
|
|
123
|
+
for mw in self._on_request:
|
|
124
|
+
result = mw(ctx)
|
|
125
|
+
if hasattr(result, "__await__"):
|
|
126
|
+
await result # type: ignore[misc]
|
|
127
|
+
|
|
128
|
+
async def _do_request() -> Any:
|
|
129
|
+
start = time.monotonic()
|
|
130
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
131
|
+
response = await client.request(
|
|
132
|
+
method=ctx.method,
|
|
133
|
+
url=ctx.url,
|
|
134
|
+
headers=ctx.headers,
|
|
135
|
+
content=body_text,
|
|
136
|
+
)
|
|
137
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
138
|
+
|
|
139
|
+
if response.status_code >= 400:
|
|
140
|
+
try:
|
|
141
|
+
error_body = response.json()
|
|
142
|
+
except Exception: # noqa: BLE001
|
|
143
|
+
error_body = response.text
|
|
144
|
+
raise CopassApiError(
|
|
145
|
+
f"API request failed: {response.status_code} {response.reason_phrase}",
|
|
146
|
+
status=response.status_code,
|
|
147
|
+
body=error_body,
|
|
148
|
+
path=path,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
for mw in self._on_response:
|
|
152
|
+
result = mw(ResponseContext(request=ctx, status=response.status_code, duration_ms=duration_ms))
|
|
153
|
+
if hasattr(result, "__await__"):
|
|
154
|
+
await result # type: ignore[misc]
|
|
155
|
+
|
|
156
|
+
if response.status_code == 204 or not response.content:
|
|
157
|
+
return None
|
|
158
|
+
return response.json()
|
|
159
|
+
|
|
160
|
+
return await retry_with_backoff(_do_request, self._retry_config)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__ = [
|
|
164
|
+
"HttpClient",
|
|
165
|
+
"HttpClientOptions",
|
|
166
|
+
"RequestOptions",
|
|
167
|
+
"RequestContext",
|
|
168
|
+
"ResponseContext",
|
|
169
|
+
"RequestMiddleware",
|
|
170
|
+
"ResponseMiddleware",
|
|
171
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Retry helper with configurable backoff.
|
|
2
|
+
|
|
3
|
+
Hand-ported from ``typescript/packages/core/src/http/retry.ts``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import re
|
|
10
|
+
from typing import Awaitable, Callable, Optional, TypeVar
|
|
11
|
+
|
|
12
|
+
from copass_core.http.errors import CopassNetworkError
|
|
13
|
+
from copass_core.types import RetryConfig
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_RETRYABLE_PATTERN = re.compile(
|
|
19
|
+
r"5\d{2}|ECONNRESET|ETIMEDOUT|ECONNREFUSED|ENOTFOUND|fetch failed|connection",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
)
|
|
22
|
+
_NETWORK_ERROR_PATTERN = re.compile(
|
|
23
|
+
r"fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|connection",
|
|
24
|
+
re.IGNORECASE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _compute_delay_ms(attempt: int, strategy: str, base_ms: int) -> int:
|
|
29
|
+
"""Compute the delay for ``attempt`` (0-indexed)."""
|
|
30
|
+
if strategy == "exponential":
|
|
31
|
+
return (2**attempt) * base_ms
|
|
32
|
+
if strategy == "linear":
|
|
33
|
+
return (attempt + 1) * base_ms
|
|
34
|
+
# fixed
|
|
35
|
+
return base_ms
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def retry_with_backoff(
|
|
39
|
+
fn: Callable[[], Awaitable[T]],
|
|
40
|
+
config: Optional[RetryConfig] = None,
|
|
41
|
+
) -> T:
|
|
42
|
+
"""Run ``fn`` with retry on transient failures.
|
|
43
|
+
|
|
44
|
+
Only retryable errors (5xx status messages, common network error
|
|
45
|
+
tokens) trigger a retry. Non-retryable failures bubble up
|
|
46
|
+
immediately. Network-level failures are wrapped in
|
|
47
|
+
:class:`CopassNetworkError` before raising.
|
|
48
|
+
"""
|
|
49
|
+
cfg = config or RetryConfig()
|
|
50
|
+
last_error: Optional[BaseException] = None
|
|
51
|
+
|
|
52
|
+
for attempt in range(cfg.max_attempts):
|
|
53
|
+
try:
|
|
54
|
+
return await fn()
|
|
55
|
+
except BaseException as error: # noqa: BLE001 — we re-classify below
|
|
56
|
+
last_error = error
|
|
57
|
+
message = str(error)
|
|
58
|
+
is_retryable = bool(_RETRYABLE_PATTERN.search(message))
|
|
59
|
+
|
|
60
|
+
if not is_retryable or attempt == cfg.max_attempts - 1:
|
|
61
|
+
if _NETWORK_ERROR_PATTERN.search(message):
|
|
62
|
+
raise CopassNetworkError(
|
|
63
|
+
"Network request failed — check your internet "
|
|
64
|
+
"connection and try again",
|
|
65
|
+
cause=error if isinstance(error, Exception) else None,
|
|
66
|
+
) from error
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
delay_ms = _compute_delay_ms(attempt, cfg.backoff_strategy, cfg.backoff_base_ms)
|
|
70
|
+
await asyncio.sleep(delay_ms / 1000)
|
|
71
|
+
|
|
72
|
+
# Unreachable — max_attempts >= 1.
|
|
73
|
+
assert last_error is not None
|
|
74
|
+
raise last_error
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["retry_with_backoff"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Resource modules — thin wrappers around specific API paths."""
|
|
2
|
+
|
|
3
|
+
from copass_core.resources.base import BaseResource
|
|
4
|
+
from copass_core.resources.context import ContextResource, ContextTier
|
|
5
|
+
from copass_core.resources.retrieval import RetrievalResource
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BaseResource",
|
|
9
|
+
"RetrievalResource",
|
|
10
|
+
"ContextResource",
|
|
11
|
+
"ContextTier",
|
|
12
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Base resource — shared :class:`HttpClient` reference + typed HTTP
|
|
2
|
+
convenience methods.
|
|
3
|
+
|
|
4
|
+
Mirrors ``typescript/packages/core/src/resources/base.ts``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from copass_core.http.http_client import HttpClient, RequestOptions
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseResource:
|
|
15
|
+
"""Base class for all API resource modules.
|
|
16
|
+
|
|
17
|
+
Concrete resources (retrieval, context, future sandboxes/etc.)
|
|
18
|
+
subclass this and call the protected helpers (:meth:`_post`,
|
|
19
|
+
:meth:`_get`, :meth:`_patch`, :meth:`_delete`) rather than going
|
|
20
|
+
through :class:`HttpClient` directly. Keeps every resource's
|
|
21
|
+
surface narrow and consistent.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, http: HttpClient) -> None:
|
|
25
|
+
self._http = http
|
|
26
|
+
|
|
27
|
+
async def _post(
|
|
28
|
+
self,
|
|
29
|
+
path: str,
|
|
30
|
+
body: Any = None,
|
|
31
|
+
*,
|
|
32
|
+
query: Optional[dict] = None,
|
|
33
|
+
headers: Optional[dict] = None,
|
|
34
|
+
) -> Any:
|
|
35
|
+
return await self._http.request(
|
|
36
|
+
path,
|
|
37
|
+
RequestOptions(method="POST", body=body, query=query, headers=headers),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def _get(
|
|
41
|
+
self,
|
|
42
|
+
path: str,
|
|
43
|
+
*,
|
|
44
|
+
query: Optional[dict] = None,
|
|
45
|
+
headers: Optional[dict] = None,
|
|
46
|
+
) -> Any:
|
|
47
|
+
return await self._http.request(
|
|
48
|
+
path,
|
|
49
|
+
RequestOptions(method="GET", query=query, headers=headers),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def _patch(
|
|
53
|
+
self,
|
|
54
|
+
path: str,
|
|
55
|
+
body: Any = None,
|
|
56
|
+
*,
|
|
57
|
+
query: Optional[dict] = None,
|
|
58
|
+
headers: Optional[dict] = None,
|
|
59
|
+
) -> Any:
|
|
60
|
+
return await self._http.request(
|
|
61
|
+
path,
|
|
62
|
+
RequestOptions(method="PATCH", body=body, query=query, headers=headers),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def _delete(
|
|
66
|
+
self,
|
|
67
|
+
path: str,
|
|
68
|
+
*,
|
|
69
|
+
query: Optional[dict] = None,
|
|
70
|
+
headers: Optional[dict] = None,
|
|
71
|
+
) -> Any:
|
|
72
|
+
return await self._http.request(
|
|
73
|
+
path,
|
|
74
|
+
RequestOptions(method="DELETE", query=query, headers=headers),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = ["BaseResource"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Context-for-agent resource — ``/api/v1/context/for-agent/*``.
|
|
2
|
+
|
|
3
|
+
Thin Python wrapper over the server-side tiered context endpoints
|
|
4
|
+
(``minimal`` / ``adaptive`` / ``comprehensive``). Shipped in v0.1.0
|
|
5
|
+
because SDK consumers (notably ``copass-anthropic-agents`` context
|
|
6
|
+
injection) need it.
|
|
7
|
+
|
|
8
|
+
The full server-side response shape is heterogeneous across tiers;
|
|
9
|
+
we return the decoded JSON dict directly rather than forcing a typed
|
|
10
|
+
response model. Callers pick the fields they care about.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
16
|
+
|
|
17
|
+
from copass_core.resources.base import BaseResource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ContextTier = Literal["minimal", "adaptive", "comprehensive"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ContextResource(BaseResource):
|
|
24
|
+
"""``/api/v1/context/for-agent/{tier}``.
|
|
25
|
+
|
|
26
|
+
Three tiers, ordered by breadth vs. cost:
|
|
27
|
+
|
|
28
|
+
- ``"minimal"`` — tight context blob, fastest.
|
|
29
|
+
- ``"adaptive"`` — lets the server pick tier based on a budget.
|
|
30
|
+
- ``"comprehensive"`` — maximum breadth, heaviest.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
async def for_agent(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
sandbox_id: str,
|
|
37
|
+
tier: ContextTier = "adaptive",
|
|
38
|
+
project_id: Optional[str] = None,
|
|
39
|
+
query: Optional[str] = None,
|
|
40
|
+
history: Optional[List[Dict[str, str]]] = None,
|
|
41
|
+
max_tokens: Optional[int] = None,
|
|
42
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
43
|
+
) -> Dict[str, Any]:
|
|
44
|
+
"""POST to the configured tier endpoint. Returns the decoded
|
|
45
|
+
JSON body.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
sandbox_id: Sandbox to retrieve context from.
|
|
49
|
+
tier: Which ``for-agent`` variant to hit.
|
|
50
|
+
project_id: Narrow to a single project within the sandbox.
|
|
51
|
+
query: Natural-language query to shape context around.
|
|
52
|
+
Optional — some tiers retrieve window-based context
|
|
53
|
+
without one.
|
|
54
|
+
history: Recent chat turns to feed the window-aware
|
|
55
|
+
retriever.
|
|
56
|
+
max_tokens: Hint for the server's context-size budget.
|
|
57
|
+
extra: Any additional body fields — escape hatch for
|
|
58
|
+
server-side flags not yet exposed via typed kwargs.
|
|
59
|
+
"""
|
|
60
|
+
body: Dict[str, Any] = {"sandbox_id": sandbox_id}
|
|
61
|
+
if project_id is not None:
|
|
62
|
+
body["project_id"] = project_id
|
|
63
|
+
if query is not None:
|
|
64
|
+
body["query"] = query
|
|
65
|
+
if history is not None:
|
|
66
|
+
body["history"] = history
|
|
67
|
+
if max_tokens is not None:
|
|
68
|
+
body["max_tokens"] = max_tokens
|
|
69
|
+
if extra:
|
|
70
|
+
body.update(extra)
|
|
71
|
+
return await self._post(f"/api/v1/context/for-agent/{tier}", body)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["ContextResource", "ContextTier"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Retrieval resource — ``discover`` / ``interpret`` / ``search``.
|
|
2
|
+
|
|
3
|
+
Hand-ported from ``typescript/packages/core/src/resources/retrieval.ts``.
|
|
4
|
+
|
|
5
|
+
All three accept either a ``window`` (a :class:`WindowLike` — typically
|
|
6
|
+
a ``ContextWindow``) or a raw ``history`` list. When ``window`` is set
|
|
7
|
+
it wins and ``history`` is ignored. Server caps at 20 turns.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from copass_core.resources.base import BaseResource
|
|
16
|
+
from copass_core.types import ChatMessage, SearchPreset, WindowLike
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _turns_from(window: Optional[WindowLike]) -> List[Dict[str, str]]:
|
|
20
|
+
if window is None:
|
|
21
|
+
return []
|
|
22
|
+
turns = window.get_turns()
|
|
23
|
+
if not turns:
|
|
24
|
+
return []
|
|
25
|
+
return [_turn_to_dict(t) for t in turns]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _turn_to_dict(turn: Any) -> Dict[str, str]:
|
|
29
|
+
"""Accept either a :class:`ChatMessage` dataclass or a plain dict
|
|
30
|
+
(``{"role": ..., "content": ...}``) so callers can mix and match."""
|
|
31
|
+
if isinstance(turn, ChatMessage):
|
|
32
|
+
return {"role": turn.role, "content": turn.content}
|
|
33
|
+
if isinstance(turn, dict) and "role" in turn and "content" in turn:
|
|
34
|
+
return {"role": str(turn["role"]), "content": str(turn["content"])}
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Unexpected turn type {type(turn).__name__}; expected ChatMessage or dict."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _history(
|
|
41
|
+
window: Optional[WindowLike],
|
|
42
|
+
history: Optional[List[Any]],
|
|
43
|
+
) -> List[Dict[str, str]]:
|
|
44
|
+
"""``window`` wins; fall back to ``history``; empty list if both None."""
|
|
45
|
+
if window is not None:
|
|
46
|
+
return _turns_from(window)
|
|
47
|
+
if history:
|
|
48
|
+
return [_turn_to_dict(t) for t in history]
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RetrievalResource(BaseResource):
|
|
53
|
+
"""``/api/v1/query/sandboxes/{sandbox_id}/{discover,interpret,search}``."""
|
|
54
|
+
|
|
55
|
+
async def discover(
|
|
56
|
+
self,
|
|
57
|
+
sandbox_id: str,
|
|
58
|
+
*,
|
|
59
|
+
query: str,
|
|
60
|
+
window: Optional[WindowLike] = None,
|
|
61
|
+
history: Optional[List[Any]] = None,
|
|
62
|
+
project_id: Optional[str] = None,
|
|
63
|
+
reference_date: Optional[str] = None,
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""Ranked menu of context items relevant to ``query``.
|
|
66
|
+
|
|
67
|
+
Window-aware: repeated calls skip items already surfaced
|
|
68
|
+
earlier in the conversation.
|
|
69
|
+
"""
|
|
70
|
+
body: Dict[str, Any] = {
|
|
71
|
+
"query": query,
|
|
72
|
+
"history": _history(window, history),
|
|
73
|
+
}
|
|
74
|
+
if project_id is not None:
|
|
75
|
+
body["project_id"] = project_id
|
|
76
|
+
if reference_date is not None:
|
|
77
|
+
body["reference_date"] = reference_date
|
|
78
|
+
return await self._post(
|
|
79
|
+
f"/api/v1/query/sandboxes/{sandbox_id}/discover",
|
|
80
|
+
body,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def interpret(
|
|
84
|
+
self,
|
|
85
|
+
sandbox_id: str,
|
|
86
|
+
*,
|
|
87
|
+
query: str,
|
|
88
|
+
items: List[List[str]],
|
|
89
|
+
window: Optional[WindowLike] = None,
|
|
90
|
+
history: Optional[List[Any]] = None,
|
|
91
|
+
project_id: Optional[str] = None,
|
|
92
|
+
reference_date: Optional[str] = None,
|
|
93
|
+
preset: Optional[SearchPreset] = None,
|
|
94
|
+
) -> Dict[str, Any]:
|
|
95
|
+
"""Synthesized 1–2 paragraph brief pinned to specific
|
|
96
|
+
``items`` picked from a prior ``discover`` call."""
|
|
97
|
+
body: Dict[str, Any] = {
|
|
98
|
+
"query": query,
|
|
99
|
+
"items": items,
|
|
100
|
+
"history": _history(window, history),
|
|
101
|
+
}
|
|
102
|
+
if project_id is not None:
|
|
103
|
+
body["project_id"] = project_id
|
|
104
|
+
if reference_date is not None:
|
|
105
|
+
body["reference_date"] = reference_date
|
|
106
|
+
if preset is not None:
|
|
107
|
+
body["preset"] = preset
|
|
108
|
+
return await self._post(
|
|
109
|
+
f"/api/v1/query/sandboxes/{sandbox_id}/interpret",
|
|
110
|
+
body,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def search(
|
|
114
|
+
self,
|
|
115
|
+
sandbox_id: str,
|
|
116
|
+
*,
|
|
117
|
+
query: str,
|
|
118
|
+
window: Optional[WindowLike] = None,
|
|
119
|
+
history: Optional[List[Any]] = None,
|
|
120
|
+
project_id: Optional[str] = None,
|
|
121
|
+
reference_date: Optional[str] = None,
|
|
122
|
+
preset: Optional[SearchPreset] = None,
|
|
123
|
+
detail_level: Optional[str] = None,
|
|
124
|
+
max_tokens: Optional[int] = None,
|
|
125
|
+
) -> Dict[str, Any]:
|
|
126
|
+
"""One-shot synthesized natural-language answer."""
|
|
127
|
+
body: Dict[str, Any] = {
|
|
128
|
+
"query": query,
|
|
129
|
+
"history": _history(window, history),
|
|
130
|
+
}
|
|
131
|
+
if project_id is not None:
|
|
132
|
+
body["project_id"] = project_id
|
|
133
|
+
if reference_date is not None:
|
|
134
|
+
body["reference_date"] = reference_date
|
|
135
|
+
if preset is not None:
|
|
136
|
+
body["preset"] = preset
|
|
137
|
+
if detail_level is not None:
|
|
138
|
+
body["detail_level"] = detail_level
|
|
139
|
+
if max_tokens is not None:
|
|
140
|
+
body["max_tokens"] = max_tokens
|
|
141
|
+
return await self._post(
|
|
142
|
+
f"/api/v1/query/sandboxes/{sandbox_id}/search",
|
|
143
|
+
body,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = ["RetrievalResource"]
|
copass_core/types.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared value types for the Copass Python client.
|
|
2
|
+
|
|
3
|
+
Hand-ported from ``typescript/packages/core/src/types/common.ts`` and
|
|
4
|
+
the retrieval/context subsets actually needed by v0.1.0 consumers.
|
|
5
|
+
Richer resource-specific types land incrementally as more resources
|
|
6
|
+
are ported.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, List, Literal, Optional, Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BackoffStrategy = Literal["exponential", "linear", "fixed"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RetryConfig:
|
|
20
|
+
"""Retry configuration for transient HTTP failures.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
max_attempts: Max total attempts (including the first). Default 3.
|
|
24
|
+
backoff_base_ms: Base delay in milliseconds.
|
|
25
|
+
backoff_strategy: ``"exponential"`` (2^attempt * base),
|
|
26
|
+
``"linear"`` ((attempt + 1) * base), or ``"fixed"`` (base).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
max_attempts: int = 3
|
|
30
|
+
backoff_base_ms: int = 1000
|
|
31
|
+
backoff_strategy: BackoffStrategy = "exponential"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ChatRole = Literal["user", "assistant", "system"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ChatMessage:
|
|
39
|
+
"""One chat turn. Mirrors the TS ``ChatMessage``."""
|
|
40
|
+
|
|
41
|
+
role: ChatRole
|
|
42
|
+
content: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WindowLike(Protocol):
|
|
46
|
+
"""Structural contract the retrieval resource accepts in place of a
|
|
47
|
+
raw ``history`` list. Any object with a ``get_turns()`` method
|
|
48
|
+
returning a list of :class:`ChatMessage` satisfies this.
|
|
49
|
+
|
|
50
|
+
Mirrors the TS ``WindowLike`` interface — the Python
|
|
51
|
+
``ContextWindow`` class (v0.2) will satisfy this protocol.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def get_turns(self) -> List[ChatMessage]: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
SearchPreset = Literal[
|
|
58
|
+
"fast",
|
|
59
|
+
"auto",
|
|
60
|
+
"discover",
|
|
61
|
+
"sql",
|
|
62
|
+
"max",
|
|
63
|
+
"fast-decompose",
|
|
64
|
+
"auto-decompose",
|
|
65
|
+
"discover-decompose",
|
|
66
|
+
"sql-decompose",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"BackoffStrategy",
|
|
72
|
+
"RetryConfig",
|
|
73
|
+
"ChatRole",
|
|
74
|
+
"ChatMessage",
|
|
75
|
+
"WindowLike",
|
|
76
|
+
"SearchPreset",
|
|
77
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: copass-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core client SDK for the Copass platform (Python mirror of @copass/core)
|
|
5
|
+
Project-URL: Homepage, https://github.com/olane-labs/copass-harness
|
|
6
|
+
Project-URL: Repository, https://github.com/olane-labs/copass-harness.git
|
|
7
|
+
Author: Olane Inc.
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: client,copass,knowledge-graph,retrieval,sdk
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# copass-core
|
|
26
|
+
|
|
27
|
+
Core client SDK for the Copass platform. Python mirror of [`@copass/core`](../../typescript/packages/core) — shared foundation for every Python Copass adapter.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install copass-core
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requires `httpx>=0.27`. Python ≥ 3.10.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import asyncio
|
|
41
|
+
from copass_core import CopassClient, ApiKeyAuth
|
|
42
|
+
|
|
43
|
+
async def main():
|
|
44
|
+
client = CopassClient(auth=ApiKeyAuth(key="olk_..."))
|
|
45
|
+
|
|
46
|
+
# Retrieval
|
|
47
|
+
menu = await client.retrieval.discover(
|
|
48
|
+
sandbox_id="sb_...",
|
|
49
|
+
query="How does auth work?",
|
|
50
|
+
)
|
|
51
|
+
print(menu["items"])
|
|
52
|
+
|
|
53
|
+
# Context for agent
|
|
54
|
+
context = await client.context.for_agent(
|
|
55
|
+
sandbox_id="sb_...",
|
|
56
|
+
tier="adaptive",
|
|
57
|
+
query="auth flow",
|
|
58
|
+
)
|
|
59
|
+
print(context)
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Auth options
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from copass_core import CopassClient, ApiKeyAuth, BearerAuth, ProviderAuth
|
|
68
|
+
|
|
69
|
+
# Long-lived API key (olk_ prefix)
|
|
70
|
+
CopassClient(auth=ApiKeyAuth(key="olk_..."))
|
|
71
|
+
|
|
72
|
+
# Raw Bearer JWT (caller owns refresh)
|
|
73
|
+
CopassClient(auth=BearerAuth(token="eyJ..."))
|
|
74
|
+
|
|
75
|
+
# Custom AuthProvider implementation
|
|
76
|
+
class MyProvider:
|
|
77
|
+
async def get_session(self):
|
|
78
|
+
from copass_core import SessionContext
|
|
79
|
+
return SessionContext(access_token=await _mint_token())
|
|
80
|
+
|
|
81
|
+
CopassClient(auth=ProviderAuth(provider=MyProvider()))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## v0.1.0 scope
|
|
85
|
+
|
|
86
|
+
**Shipped:**
|
|
87
|
+
- `CopassClient` top-level entry point
|
|
88
|
+
- Auth providers: `ApiKeyAuthProvider`, `BearerAuthProvider`
|
|
89
|
+
- `HttpClient` with retry + request/response middleware
|
|
90
|
+
- `RetrievalResource` — `discover` / `interpret` / `search`
|
|
91
|
+
- `ContextResource` — `/context/for-agent/{minimal,adaptive,comprehensive}`
|
|
92
|
+
|
|
93
|
+
**Deferred to a future release:**
|
|
94
|
+
- Crypto primitives (HKDF, AES-GCM, session tokens, DEK) + Supabase auth provider
|
|
95
|
+
- `ContextWindow` + `SourcesResource` + `IngestResource`
|
|
96
|
+
- `MatrixResource`, `SandboxesResource`, `ProjectsResource`, `EntitiesResource`, `VaultResource`, `UsageResource`, `ApiKeysResource`, `UsersResource`
|
|
97
|
+
|
|
98
|
+
Add incrementally when a concrete Python consumer needs each. Opening a PR with a scoped addition is the expected path.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
copass_core/__init__.py,sha256=tCnaYxjvbjN_e5kAjEwgIBcJWb7NjjNEWR6p2Ys0JdQ,2245
|
|
2
|
+
copass_core/client.py,sha256=TwiYn-7WVVtp1Ztkn8iD6UdIv-1SlHSkiSTCPBoZmSo,3364
|
|
3
|
+
copass_core/types.py,sha256=33W3eg0XE45uu4dlahY6sIoPYOgs7vGNElrjClO9gr0,1890
|
|
4
|
+
copass_core/auth/__init__.py,sha256=30P2JKrGkEiXAij14pyXeITTIPStgumf6Wxg2Ht7yqE,317
|
|
5
|
+
copass_core/auth/api_key.py,sha256=rdLvm1q7cl21k2QogyTnYlagKJr27mlRLCHmJGgmmpQ,686
|
|
6
|
+
copass_core/auth/bearer.py,sha256=lClUY1sICjrhG8RBLMnxu7N2vWo9jnDlAPiydWiL4gw,1260
|
|
7
|
+
copass_core/auth/types.py,sha256=lXaRDYvsXjKiY_n8NME02LytiJAiQTP6IuUwnszO2dE,1417
|
|
8
|
+
copass_core/http/__init__.py,sha256=moHB5u-R8x0wKp2l-tLN-V13ugip5668u2aC45zOgVY,667
|
|
9
|
+
copass_core/http/errors.py,sha256=RKffCQfkWB3tjFLc9Nf8b2rRnis66As-mMleL-wpOr0,1531
|
|
10
|
+
copass_core/http/http_client.py,sha256=aiWV0ARsgXtAGp4rs9xJEEEv2xW7I0qG-XlBUet9nWQ,5650
|
|
11
|
+
copass_core/http/retry.py,sha256=fSdcT4jggqoWbuGeeeLVSleuYrS0-IGvdTsn8trKw-Y,2428
|
|
12
|
+
copass_core/resources/__init__.py,sha256=rkzSZH0VktglD30YyT1drimoM_qAPTSMbFjxx2KJB0A,356
|
|
13
|
+
copass_core/resources/base.py,sha256=uo_cK7oCUBehcHsk5Vq73j2YwZ289Ti_4Ln8BCUJIFM,2060
|
|
14
|
+
copass_core/resources/context.py,sha256=VkCzdAop4sBFknRcRhO4QyWtKyU9k9EiuAKz8errDIM,2659
|
|
15
|
+
copass_core/resources/retrieval.py,sha256=bdEWdh0Urb6oaMeuiEToaXNAYFjBg0CIuxjIUZq2SVM,4888
|
|
16
|
+
copass_core-0.1.0.dist-info/METADATA,sha256=Gq6kRHhOSFGEAP3u4Nq04jPJ4DGlV-mp4rsBypgQbRg,3064
|
|
17
|
+
copass_core-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
18
|
+
copass_core-0.1.0.dist-info/RECORD,,
|