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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any