cognicloud 0.9.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.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: cognicloud
3
+ Version: 0.9.0
4
+ Summary: CogniCloud SDK: browser sessions for agents, and the platform API.
5
+ License: LicenseRef-Proprietary
6
+ Project-URL: Source, https://github.com/Flippico/cognicloud-python-sdk
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # cognicloud
11
+
12
+ Use CogniCloud from Python. Version 0.4.0 covers browser sessions for agents; the other products come later. Same behaviour as [`@cognicloud/sdk`](https://github.com/Flippico/cognicloud-js-sdk) and [`cognicloud-sdk`](https://github.com/Flippico/cognicloud-rust-sdk).
13
+
14
+ ## Install
15
+
16
+ Python 3.10 or newer. No dependencies.
17
+
18
+ ```bash
19
+ pip install "git+https://github.com/Flippico/cognicloud-python-sdk.git"
20
+ ```
21
+
22
+ ## Browser sessions
23
+
24
+ ```python
25
+ from cognicloud import CogniCloud
26
+
27
+ cc = CogniCloud(amadeus_token)
28
+ session = cc.browser.sessions.create(timeout_minutes=15)
29
+ print(session.cdp_url) # connect Playwright, Puppeteer or any CDP client here
30
+ cc.browser.sessions.stop(session.id)
31
+ ```
32
+
33
+ - `timeout_minutes` is 1 to 240 and defaults to 15.
34
+ - Closing the CDP connection does not stop a session; call `sessions.stop`.
35
+ - `status` is `active` or `stopped`; `cdp_url` and `live_url` are `None` once stopped. `live_url` is a page where a person can watch and take over the browser.
36
+ - `sessions.list()` returns `items` and `concurrency` (`active`, `limit`). A start beyond the limit raises `CogniCloudError` with `status == 429` and `code == "concurrency_limit"`.
37
+ - Proxy countries, profiles, screen sizes, captcha solving and recording are not available yet. `browser.countries()` and `browser.profiles()` return empty lists.
38
+
39
+ ## Call an endpoint
40
+
41
+ ```python
42
+ import os
43
+ from cognicloud import EndpointClient, ResponsesRequest, McpTool, EmbeddingsRequest, DeltaEvent, DoneEvent, ErrorEvent
44
+
45
+ endpoint = EndpointClient(os.environ["COGNICLOUD_KEY"], endpoint_id=os.environ["COGNICLOUD_ENDPOINT_ID"])
46
+
47
+ answer = endpoint.responses(
48
+ ResponsesRequest(
49
+ input="Is this invoice valid?",
50
+ instructions="Check invoices.",
51
+ tools=[McpTool("ksef", allowed_tools=["validate_invoice_xml"])],
52
+ tool_choice="required",
53
+ )
54
+ )
55
+ next_answer = endpoint.responses(ResponsesRequest("And this one?", previous_response_id=answer.response_id))
56
+
57
+ vectors = endpoint.embeddings(EmbeddingsRequest(["first text", "second text"], dimensions=256))
58
+ ```
59
+
60
+ `McpTool` names an MCP server with `server_url` and `bearer_token`. The gateway checks the address (https, with no credentials or fragment) and passes both to the provider. A tool with only `server_label` names a server the gateway is configured with. The answer's `response_id` continues the conversation on the same endpoint, and `output_items` holds the tool calls. `embeddings` needs an endpoint whose deployment is an embedding model, and returns one vector per input.
61
+
62
+ ## Streaming
63
+
64
+ ```python
65
+ for event in endpoint.stream({"messages": [{"role": "user", "content": "Write a haiku."}]}):
66
+ if isinstance(event, DeltaEvent):
67
+ print(event.text, end="")
68
+ elif isinstance(event, DoneEvent):
69
+ print(f"\n{event.answer.usage.total_tokens} tokens")
70
+ elif isinstance(event, ErrorEvent):
71
+ print(f"{event.code}: {event.message}")
72
+ ```
73
+
74
+ `ResponsesRequest(extra={...})` sends any other field to the provider unchanged, such as `seed` or `reasoning`. The gateway refuses the fields it sets itself, such as `n` or `store`.
75
+
76
+ `stream` opens the call before it returns, so a refusal raises immediately, before any event. Each event is a `DeltaEvent` (`text`), then one `DoneEvent` (`answer`), or one `ErrorEvent` (`code`, `message`). Streams are not retried and have no total timeout; the gateway ends them at five minutes. Stop early by closing the iterator; the gateway still settles the call.
77
+
78
+ ## Errors
79
+
80
+ Every failed request raises `CogniCloudError` with `status` (the HTTP status, `0` when no answer came back), `code` (CogniCloud's stable error code, or a local one such as `timeout` or `network_error`) and `message`.
81
+
82
+ ## Configuration
83
+
84
+ | Option | Default | |
85
+ | --- | --- | --- |
86
+ | `token` | required | The signed-in person's Amadeus session token. |
87
+ | `tenant` | `"cognicloud"` | The workspace, sent as `tenant_id`. The platform workspace is open to every signed-in user. |
88
+ | `locale` | `"en"` | `Accept-Language` for localised responses. |
89
+ | `timeout` | `30.0` | Seconds per request. |
90
+ | `max_retries` | `2` | For `429` and `5xx`, on reads only. Starts and stops are never retried. |
91
+ | `browser_base_url` | `https://cognicloud.flippi.co/api/v1/browser` | Point at another environment. |
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ PYTHONPATH=src python -m unittest discover -s tests
97
+ ```
98
+
99
+ The tests run against a local HTTP stub and need no network access.
100
+
101
+ See [CHANGELOG.md](./CHANGELOG.md).
@@ -0,0 +1,92 @@
1
+ # cognicloud
2
+
3
+ Use CogniCloud from Python. Version 0.4.0 covers browser sessions for agents; the other products come later. Same behaviour as [`@cognicloud/sdk`](https://github.com/Flippico/cognicloud-js-sdk) and [`cognicloud-sdk`](https://github.com/Flippico/cognicloud-rust-sdk).
4
+
5
+ ## Install
6
+
7
+ Python 3.10 or newer. No dependencies.
8
+
9
+ ```bash
10
+ pip install "git+https://github.com/Flippico/cognicloud-python-sdk.git"
11
+ ```
12
+
13
+ ## Browser sessions
14
+
15
+ ```python
16
+ from cognicloud import CogniCloud
17
+
18
+ cc = CogniCloud(amadeus_token)
19
+ session = cc.browser.sessions.create(timeout_minutes=15)
20
+ print(session.cdp_url) # connect Playwright, Puppeteer or any CDP client here
21
+ cc.browser.sessions.stop(session.id)
22
+ ```
23
+
24
+ - `timeout_minutes` is 1 to 240 and defaults to 15.
25
+ - Closing the CDP connection does not stop a session; call `sessions.stop`.
26
+ - `status` is `active` or `stopped`; `cdp_url` and `live_url` are `None` once stopped. `live_url` is a page where a person can watch and take over the browser.
27
+ - `sessions.list()` returns `items` and `concurrency` (`active`, `limit`). A start beyond the limit raises `CogniCloudError` with `status == 429` and `code == "concurrency_limit"`.
28
+ - Proxy countries, profiles, screen sizes, captcha solving and recording are not available yet. `browser.countries()` and `browser.profiles()` return empty lists.
29
+
30
+ ## Call an endpoint
31
+
32
+ ```python
33
+ import os
34
+ from cognicloud import EndpointClient, ResponsesRequest, McpTool, EmbeddingsRequest, DeltaEvent, DoneEvent, ErrorEvent
35
+
36
+ endpoint = EndpointClient(os.environ["COGNICLOUD_KEY"], endpoint_id=os.environ["COGNICLOUD_ENDPOINT_ID"])
37
+
38
+ answer = endpoint.responses(
39
+ ResponsesRequest(
40
+ input="Is this invoice valid?",
41
+ instructions="Check invoices.",
42
+ tools=[McpTool("ksef", allowed_tools=["validate_invoice_xml"])],
43
+ tool_choice="required",
44
+ )
45
+ )
46
+ next_answer = endpoint.responses(ResponsesRequest("And this one?", previous_response_id=answer.response_id))
47
+
48
+ vectors = endpoint.embeddings(EmbeddingsRequest(["first text", "second text"], dimensions=256))
49
+ ```
50
+
51
+ `McpTool` names an MCP server with `server_url` and `bearer_token`. The gateway checks the address (https, with no credentials or fragment) and passes both to the provider. A tool with only `server_label` names a server the gateway is configured with. The answer's `response_id` continues the conversation on the same endpoint, and `output_items` holds the tool calls. `embeddings` needs an endpoint whose deployment is an embedding model, and returns one vector per input.
52
+
53
+ ## Streaming
54
+
55
+ ```python
56
+ for event in endpoint.stream({"messages": [{"role": "user", "content": "Write a haiku."}]}):
57
+ if isinstance(event, DeltaEvent):
58
+ print(event.text, end="")
59
+ elif isinstance(event, DoneEvent):
60
+ print(f"\n{event.answer.usage.total_tokens} tokens")
61
+ elif isinstance(event, ErrorEvent):
62
+ print(f"{event.code}: {event.message}")
63
+ ```
64
+
65
+ `ResponsesRequest(extra={...})` sends any other field to the provider unchanged, such as `seed` or `reasoning`. The gateway refuses the fields it sets itself, such as `n` or `store`.
66
+
67
+ `stream` opens the call before it returns, so a refusal raises immediately, before any event. Each event is a `DeltaEvent` (`text`), then one `DoneEvent` (`answer`), or one `ErrorEvent` (`code`, `message`). Streams are not retried and have no total timeout; the gateway ends them at five minutes. Stop early by closing the iterator; the gateway still settles the call.
68
+
69
+ ## Errors
70
+
71
+ Every failed request raises `CogniCloudError` with `status` (the HTTP status, `0` when no answer came back), `code` (CogniCloud's stable error code, or a local one such as `timeout` or `network_error`) and `message`.
72
+
73
+ ## Configuration
74
+
75
+ | Option | Default | |
76
+ | --- | --- | --- |
77
+ | `token` | required | The signed-in person's Amadeus session token. |
78
+ | `tenant` | `"cognicloud"` | The workspace, sent as `tenant_id`. The platform workspace is open to every signed-in user. |
79
+ | `locale` | `"en"` | `Accept-Language` for localised responses. |
80
+ | `timeout` | `30.0` | Seconds per request. |
81
+ | `max_retries` | `2` | For `429` and `5xx`, on reads only. Starts and stops are never retried. |
82
+ | `browser_base_url` | `https://cognicloud.flippi.co/api/v1/browser` | Point at another environment. |
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ PYTHONPATH=src python -m unittest discover -s tests
88
+ ```
89
+
90
+ The tests run against a local HTTP stub and need no network access.
91
+
92
+ See [CHANGELOG.md](./CHANGELOG.md).
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cognicloud"
7
+ version = "0.9.0"
8
+ description = "CogniCloud SDK: browser sessions for agents, and the platform API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "LicenseRef-Proprietary" }
12
+ dependencies = []
13
+
14
+ [project.urls]
15
+ Source = "https://github.com/Flippico/cognicloud-python-sdk"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,52 @@
1
+ """CogniCloud SDK for Python. Standard library only; needs Python 3.10 or newer."""
2
+
3
+ from .browser import (
4
+ BrowserApi,
5
+ BrowserConcurrency,
6
+ BrowserProfile,
7
+ BrowserSession,
8
+ BrowserSessionList,
9
+ BrowserSessionsApi,
10
+ ProxyCountry,
11
+ )
12
+ from .client import CogniCloud
13
+ from .endpoint import (
14
+ DeltaEvent,
15
+ DoneEvent,
16
+ Embedding,
17
+ EmbeddingsRequest,
18
+ EmbeddingsResult,
19
+ EndpointClient,
20
+ ErrorEvent,
21
+ McpTool,
22
+ ResponseResult,
23
+ ResponsesRequest,
24
+ StreamEvent,
25
+ Usage,
26
+ )
27
+ from .errors import CogniCloudError
28
+
29
+ __all__ = [
30
+ "BrowserApi",
31
+ "BrowserConcurrency",
32
+ "BrowserProfile",
33
+ "BrowserSession",
34
+ "BrowserSessionList",
35
+ "BrowserSessionsApi",
36
+ "CogniCloud",
37
+ "CogniCloudError",
38
+ "DeltaEvent",
39
+ "DoneEvent",
40
+ "Embedding",
41
+ "EmbeddingsRequest",
42
+ "EmbeddingsResult",
43
+ "EndpointClient",
44
+ "ErrorEvent",
45
+ "McpTool",
46
+ "ProxyCountry",
47
+ "ResponseResult",
48
+ "ResponsesRequest",
49
+ "StreamEvent",
50
+ "Usage",
51
+ ]
52
+ __version__ = "0.9.0"
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ import time
6
+ import urllib.error
7
+ import urllib.request
8
+ from dataclasses import dataclass
9
+ from typing import Any, Optional
10
+
11
+ from .errors import CogniCloudError
12
+
13
+ RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class HttpConfig:
18
+ base_url: str
19
+ locale: str
20
+ timeout: float
21
+ max_retries: int
22
+
23
+
24
+ def request(
25
+ config: HttpConfig,
26
+ method: str,
27
+ path: str,
28
+ headers: dict[str, str],
29
+ body: Optional[Any] = None,
30
+ retry: bool = True,
31
+ ) -> Any:
32
+ """Sends one request and returns the decoded JSON body (None for 204). Retries 429 and 5xx only when `retry` is set."""
33
+ url = config.base_url.rstrip("/") + path
34
+ retries = config.max_retries if retry else 0
35
+ payload = None if body is None else json.dumps(body).encode("utf-8")
36
+ all_headers = {"accept": "application/json", "accept-language": config.locale, **headers}
37
+ if payload is not None:
38
+ all_headers["content-type"] = "application/json"
39
+
40
+ attempt = 0
41
+ while True:
42
+ http_request = urllib.request.Request(url, data=payload, method=method, headers=all_headers)
43
+ try:
44
+ with urllib.request.urlopen(http_request, timeout=config.timeout) as response:
45
+ raw = response.read()
46
+ return None if response.status == 204 or not raw else json.loads(raw)
47
+ except urllib.error.HTTPError as error:
48
+ if error.code in RETRYABLE_STATUSES and attempt < retries:
49
+ time.sleep(0.25 * 2**attempt)
50
+ attempt += 1
51
+ continue
52
+ raise _api_error(error.code, error.read()) from None
53
+ except (TimeoutError, socket.timeout):
54
+ if attempt < retries:
55
+ time.sleep(0.25 * 2**attempt)
56
+ attempt += 1
57
+ continue
58
+ raise CogniCloudError(0, "timeout", "The request timed out.") from None
59
+ except urllib.error.URLError as error:
60
+ timed_out = isinstance(error.reason, (TimeoutError, socket.timeout))
61
+ if attempt < retries:
62
+ time.sleep(0.25 * 2**attempt)
63
+ attempt += 1
64
+ continue
65
+ if timed_out:
66
+ raise CogniCloudError(0, "timeout", "The request timed out.") from None
67
+ raise CogniCloudError(0, "network_error", "The request could not reach CogniCloud.") from None
68
+
69
+
70
+ def _api_error(status: int, raw: bytes) -> CogniCloudError:
71
+ try:
72
+ body = json.loads(raw) if raw else {}
73
+ except ValueError:
74
+ body = {}
75
+ code = body.get("error") if isinstance(body.get("error"), str) else "http_error"
76
+ message = body.get("message") if isinstance(body.get("message"), str) else "The request failed."
77
+ return CogniCloudError(status, code, message)
78
+
79
+
80
+ def open_stream(
81
+ config: HttpConfig,
82
+ method: str,
83
+ path: str,
84
+ headers: dict[str, str],
85
+ body: Optional[Any] = None,
86
+ ) -> Any:
87
+ """Opens a streamed call and returns the response to read as it arrives. Never retried, and the timeout bounds each
88
+ read rather than the whole stream, because a stream may run for minutes."""
89
+ url = config.base_url.rstrip("/") + path
90
+ payload = None if body is None else json.dumps(body).encode("utf-8")
91
+ all_headers = {"accept": "text/event-stream", "accept-language": config.locale, **headers}
92
+ if payload is not None:
93
+ all_headers["content-type"] = "application/json"
94
+ http_request = urllib.request.Request(url, data=payload, method=method, headers=all_headers)
95
+ try:
96
+ return urllib.request.urlopen(http_request, timeout=config.timeout)
97
+ except urllib.error.HTTPError as error:
98
+ raise _api_error(error.code, error.read()) from None
99
+ except (TimeoutError, socket.timeout):
100
+ raise CogniCloudError(0, "timeout", "The request timed out.") from None
101
+ except urllib.error.URLError as error:
102
+ if isinstance(error.reason, (TimeoutError, socket.timeout)):
103
+ raise CogniCloudError(0, "timeout", "The request timed out.") from None
104
+ raise CogniCloudError(0, "network_error", "The request could not reach CogniCloud.") from None
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Iterator
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Record:
10
+ event: str
11
+ data: str
12
+
13
+
14
+ def records(lines: Iterable[bytes]) -> Iterator[Record]:
15
+ """Splits Server-Sent Events into records. Lines may end in LF or CRLF; a record ends at a blank line, and its
16
+ `data:` lines are joined with newlines. A record without an `event:` line is named `message`."""
17
+ event: Optional[str] = None
18
+ data: list[str] = []
19
+ for raw in lines:
20
+ line = raw.decode("utf-8").rstrip("\r\n")
21
+ if line == "":
22
+ if event is not None or data:
23
+ yield Record(event or "message", "\n".join(data))
24
+ event = None
25
+ data = []
26
+ elif line.startswith("event:"):
27
+ event = _value(line[len("event:") :])
28
+ elif line.startswith("data:"):
29
+ data.append(_value(line[len("data:") :]))
30
+
31
+
32
+ def _value(text: str) -> str:
33
+ return text[1:] if text.startswith(" ") else text
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Optional
5
+ from urllib.parse import quote
6
+
7
+ from ._http import HttpConfig, request
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class BrowserSession:
12
+ """A session is `active` until it stops; then `cdp_url` and `live_url` are None."""
13
+
14
+ id: str
15
+ status: str
16
+ cdp_url: Optional[str]
17
+ live_url: Optional[str]
18
+ profile_id: Optional[str]
19
+ proxy_country_code: Optional[str]
20
+ solve_captchas: bool
21
+ recording: bool
22
+ screen: Optional[dict[str, int]]
23
+ timeout_at: str
24
+ started_at: str
25
+ finished_at: Optional[str]
26
+
27
+ @classmethod
28
+ def from_json(cls, data: dict[str, Any]) -> "BrowserSession":
29
+ return cls(
30
+ id=data["id"],
31
+ status=data["status"],
32
+ cdp_url=data.get("cdpUrl"),
33
+ live_url=data.get("liveUrl"),
34
+ profile_id=data.get("profileId"),
35
+ proxy_country_code=data.get("proxyCountryCode"),
36
+ solve_captchas=bool(data.get("solveCaptchas")),
37
+ recording=bool(data.get("recording")),
38
+ screen=data.get("screen"),
39
+ timeout_at=data["timeoutAt"],
40
+ started_at=data["startedAt"],
41
+ finished_at=data.get("finishedAt"),
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class BrowserConcurrency:
47
+ active: int
48
+ limit: int
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class BrowserSessionList:
53
+ items: list[BrowserSession]
54
+ concurrency: BrowserConcurrency
55
+
56
+ @classmethod
57
+ def from_json(cls, data: dict[str, Any]) -> "BrowserSessionList":
58
+ concurrency = data["concurrency"]
59
+ return cls(
60
+ items=[BrowserSession.from_json(item) for item in data["items"]],
61
+ concurrency=BrowserConcurrency(active=concurrency["active"], limit=concurrency["limit"]),
62
+ )
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ProxyCountry:
67
+ code: str
68
+ name: str
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class BrowserProfile:
73
+ id: str
74
+ name: str
75
+ cookie_domains: list[str]
76
+ last_used_at: Optional[str]
77
+ created_at: str
78
+
79
+
80
+ class BrowserSessionsApi:
81
+ """Starts, lists, reads and stops browser sessions."""
82
+
83
+ def __init__(self, config: HttpConfig, headers: dict[str, str]) -> None:
84
+ self._config = config
85
+ self._headers = headers
86
+
87
+ def create(self, timeout_minutes: Optional[int] = None) -> BrowserSession:
88
+ """Starts a session. Its `cdp_url` and `live_url` carry the session token; treat them as secrets.
89
+
90
+ `timeout_minutes` is 1 to 240 and defaults to 15. A start beyond the workspace's limit raises
91
+ CogniCloudError with status 429 and code `concurrency_limit`.
92
+ """
93
+ body: dict[str, Any] = {}
94
+ if timeout_minutes is not None:
95
+ body["timeoutMinutes"] = timeout_minutes
96
+ data = request(self._config, "POST", "/sessions", self._headers, body, retry=False)
97
+ return BrowserSession.from_json(data)
98
+
99
+ def list(self) -> BrowserSessionList:
100
+ """The workspace's sessions, newest first, with concurrency."""
101
+ return BrowserSessionList.from_json(request(self._config, "GET", "/sessions", self._headers))
102
+
103
+ def get(self, session_id: str) -> BrowserSession:
104
+ return BrowserSession.from_json(
105
+ request(self._config, "GET", f"/sessions/{quote(session_id, safe='')}", self._headers)
106
+ )
107
+
108
+ def stop(self, session_id: str) -> BrowserSession:
109
+ """Closing the CDP connection does not stop a session; this does."""
110
+ data = request(
111
+ self._config, "POST", f"/sessions/{quote(session_id, safe='')}/stop", self._headers, retry=False
112
+ )
113
+ return BrowserSession.from_json(data)
114
+
115
+
116
+ class BrowserApi:
117
+ """Manages the browser product with the signed-in person's session."""
118
+
119
+ def __init__(self, config: HttpConfig, headers: dict[str, str]) -> None:
120
+ self._config = config
121
+ self._headers = headers
122
+ self.sessions = BrowserSessionsApi(config, headers)
123
+
124
+ def countries(self) -> list[ProxyCountry]:
125
+ """Proxy countries a session can route through. Empty until proxies are available."""
126
+ data = request(self._config, "GET", "/countries", self._headers)
127
+ return [ProxyCountry(code=item["code"], name=item["name"]) for item in data]
128
+
129
+ def profiles(self) -> list[BrowserProfile]:
130
+ """Profiles keep cookies between sessions. Empty until profiles are available."""
131
+ data = request(self._config, "GET", "/profiles", self._headers)
132
+ return [
133
+ BrowserProfile(
134
+ id=item["id"],
135
+ name=item["name"],
136
+ cookie_domains=item["cookieDomains"],
137
+ last_used_at=item.get("lastUsedAt"),
138
+ created_at=item["createdAt"],
139
+ )
140
+ for item in data
141
+ ]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ._http import HttpConfig
6
+ from .browser import BrowserApi
7
+
8
+ DEFAULT_TENANT = "cognicloud"
9
+ DEFAULT_BROWSER_BASE_URL = "https://cognicloud.flippi.co/api/v1/browser"
10
+
11
+
12
+ class CogniCloud:
13
+ """Manage CogniCloud resources with the signed-in person's session.
14
+
15
+ `token` is the Amadeus session token, sent as `Authorization: Bearer`. `tenant` is the workspace, sent as the
16
+ `tenant_id` header; it defaults to the platform workspace, which every signed-in user may use.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ token: str,
22
+ *,
23
+ tenant: str = DEFAULT_TENANT,
24
+ locale: str = "en",
25
+ timeout: float = 30.0,
26
+ max_retries: int = 2,
27
+ browser_base_url: str = DEFAULT_BROWSER_BASE_URL,
28
+ ) -> None:
29
+ config = HttpConfig(base_url=browser_base_url, locale=locale, timeout=timeout, max_retries=max_retries)
30
+ headers = {"authorization": f"Bearer {token}", "tenant_id": tenant}
31
+ self.browser = BrowserApi(config, headers)
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ from collections.abc import Iterator, Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal, Optional, Union
8
+ from urllib.parse import quote
9
+
10
+ from ._http import HttpConfig, open_stream, request
11
+ from ._sse import Record, records
12
+ from .errors import CogniCloudError
13
+
14
+ DEFAULT_TENANT = "cognicloud"
15
+ DEFAULT_GATEWAY_BASE_URL = "https://cognicloud.flippi.co/api/v1/ai-gateway"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class McpTool:
20
+ """An MCP server a Responses call may use.
21
+
22
+ Name the server with `server_url` and its bearer token with `bearer_token`. The gateway checks the address (https,
23
+ no credentials or fragment), sets approval to `never`, and passes both to the provider. A tool with only
24
+ `server_label` names a server the gateway is configured with.
25
+ """
26
+
27
+ server_label: str
28
+ allowed_tools: Optional[list[str]] = None
29
+ server_url: Optional[str] = None
30
+ bearer_token: Optional[str] = field(default=None, repr=False)
31
+
32
+ def to_json(self) -> dict[str, Any]:
33
+ body: dict[str, Any] = {"type": "mcp", "server_label": self.server_label}
34
+ if self.server_url is not None:
35
+ body["server_url"] = self.server_url
36
+ if self.bearer_token is not None:
37
+ body["headers"] = {"Authorization": f"Bearer {self.bearer_token}"}
38
+ if self.allowed_tools is not None:
39
+ body["allowed_tools"] = self.allowed_tools
40
+ return body
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ResponsesRequest:
45
+ """A Responses call with optional MCP tools, instructions and chaining. `input` is text or role/content messages."""
46
+
47
+ input: Union[str, list[dict[str, Any]]]
48
+ instructions: Optional[str] = None
49
+ previous_response_id: Optional[str] = None
50
+ tools: list[McpTool] = field(default_factory=list)
51
+ tool_choice: Optional[Literal["auto", "required", "none"]] = None
52
+ max_output_tokens: Optional[int] = None
53
+ temperature: Optional[float] = None
54
+ top_p: Optional[float] = None
55
+ # Any other field, sent to the provider unchanged. The gateway refuses the fields it sets itself, such as `n`.
56
+ extra: dict[str, Any] = field(default_factory=dict)
57
+
58
+ def to_json(self) -> dict[str, Any]:
59
+ body: dict[str, Any] = {**self.extra, "input": self.input}
60
+ optional = {
61
+ "instructions": self.instructions,
62
+ "previous_response_id": self.previous_response_id,
63
+ "tool_choice": self.tool_choice,
64
+ "max_output_tokens": self.max_output_tokens,
65
+ "temperature": self.temperature,
66
+ "top_p": self.top_p,
67
+ }
68
+ body.update({key: value for key, value in optional.items() if value is not None})
69
+ if self.tools:
70
+ body["tools"] = [tool.to_json() for tool in self.tools]
71
+ return body
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class EmbeddingsRequest:
76
+ """Text to embed: one string or up to 256. The endpoint's embedding deployment is the model."""
77
+
78
+ input: Union[str, list[str]]
79
+ dimensions: Optional[int] = None
80
+
81
+ def to_json(self) -> dict[str, Any]:
82
+ body: dict[str, Any] = {"input": self.input}
83
+ if self.dimensions is not None:
84
+ body["dimensions"] = self.dimensions
85
+ return body
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Usage:
90
+ input_tokens: int
91
+ output_tokens: int
92
+ total_tokens: int
93
+
94
+ @classmethod
95
+ def from_json(cls, data: dict[str, Any]) -> "Usage":
96
+ return cls(data["inputTokens"], data["outputTokens"], data["totalTokens"])
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ResponseResult:
101
+ """An answer. `response_id` and `output_items` are set only for Responses calls."""
102
+
103
+ id: str
104
+ model: str
105
+ output: str
106
+ finish_reason: Optional[str]
107
+ usage: Usage
108
+ response_id: Optional[str] = None
109
+ output_items: Optional[list[Any]] = None
110
+
111
+ @classmethod
112
+ def from_json(cls, data: dict[str, Any]) -> "ResponseResult":
113
+ return cls(
114
+ id=data["id"],
115
+ model=data["model"],
116
+ output=data["output"],
117
+ finish_reason=data.get("finishReason"),
118
+ usage=Usage.from_json(data["usage"]),
119
+ response_id=data.get("responseId"),
120
+ output_items=data.get("outputItems"),
121
+ )
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class Embedding:
126
+ index: int
127
+ embedding: list[float]
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class EmbeddingsResult:
132
+ """One item per input, in input order."""
133
+
134
+ id: str
135
+ model: str
136
+ data: list[Embedding]
137
+ usage: Usage
138
+
139
+ @classmethod
140
+ def from_json(cls, data: dict[str, Any]) -> "EmbeddingsResult":
141
+ return cls(
142
+ id=data["id"],
143
+ model=data["model"],
144
+ data=[Embedding(item["index"], item["embedding"]) for item in data["data"]],
145
+ usage=Usage.from_json(data["usage"]),
146
+ )
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class DeltaEvent:
151
+ """Text as it is generated."""
152
+
153
+ text: str
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class DoneEvent:
158
+ """The complete answer. It is always the last event of a successful stream."""
159
+
160
+ answer: ResponseResult
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class ErrorEvent:
165
+ """A failure, instead of `DoneEvent`. The stream ends after it."""
166
+
167
+ code: str
168
+ message: str
169
+
170
+
171
+ StreamEvent = Union[DeltaEvent, DoneEvent, ErrorEvent]
172
+
173
+
174
+ def encode_tenant(tenant: str) -> str:
175
+ """How an invoke URL names its tenant: the tenant's name as UTF-8, in lowercase hex."""
176
+ return tenant.encode("utf-8").hex()
177
+
178
+
179
+ class EndpointClient:
180
+ """Calls one AI Gateway endpoint with its own credential.
181
+
182
+ Pass the endpoint's invoke URL as `url` (as CogniCloud issued it), or the `endpoint_id` and `tenant`. Invoke calls
183
+ are never retried, because a retried call can be billed twice.
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ credential: str,
189
+ *,
190
+ endpoint_id: Optional[str] = None,
191
+ url: Optional[str] = None,
192
+ tenant: str = DEFAULT_TENANT,
193
+ base_url: str = DEFAULT_GATEWAY_BASE_URL,
194
+ locale: str = "en",
195
+ timeout: float = 30.0,
196
+ max_retries: int = 2,
197
+ ) -> None:
198
+ if (endpoint_id is None) == (url is None):
199
+ raise ValueError("Pass exactly one of endpoint_id or url.")
200
+ if url is None:
201
+ url = f"{base_url.rstrip('/')}/invoke/{encode_tenant(tenant)}/{quote(endpoint_id or '', safe='')}"
202
+ self.url = url
203
+ self._config = HttpConfig(base_url=url, locale=locale, timeout=timeout, max_retries=max_retries)
204
+ self._headers = {"authorization": f"Bearer {credential}"}
205
+
206
+ def invoke(self, body: Mapping[str, Any]) -> ResponseResult:
207
+ """Sends the body through the endpoint's request mapping and returns the answer in one shape. Without a mapping
208
+ the body goes to the provider as it is, with no streaming. Use `stream` to stream."""
209
+ data = request(self._config, "POST", "", self._headers, dict(body), retry=False)
210
+ return ResponseResult.from_json(data)
211
+
212
+ def responses(self, request_body: ResponsesRequest) -> ResponseResult:
213
+ """A Responses call with optional MCP tools, instructions and chaining. Never retried."""
214
+ data = request(self._config, "POST", "", self._headers, request_body.to_json(), retry=False)
215
+ return ResponseResult.from_json(data)
216
+
217
+ def embeddings(self, request_body: EmbeddingsRequest) -> EmbeddingsResult:
218
+ """Embeds text through the endpoint's embedding deployment. Billed as prompt tokens. Never retried."""
219
+ data = request(self._config, "POST", "/embeddings", self._headers, request_body.to_json(), retry=False)
220
+ return EmbeddingsResult.from_json(data)
221
+
222
+ def stream(self, body: Union[Mapping[str, Any], ResponsesRequest]) -> Iterator[StreamEvent]:
223
+ """Streams a chat or Responses call: sets `stream: true` and yields events as they arrive.
224
+
225
+ The call is opened before this returns, so a refusal raises here, before any event. Stop early by closing the
226
+ iterator; the gateway still settles the call.
227
+ """
228
+ payload = dict(body.to_json() if isinstance(body, ResponsesRequest) else body)
229
+ payload["stream"] = True
230
+ response = open_stream(self._config, "POST", "", self._headers, payload)
231
+ return _events(response)
232
+
233
+ def stream_responses(self, request_body: ResponsesRequest) -> Iterator[StreamEvent]:
234
+ """Streams a Responses call, for tools or chaining. See `stream`."""
235
+ return self.stream(request_body)
236
+
237
+
238
+ def _events(response: Any) -> Iterator[StreamEvent]:
239
+ try:
240
+ for record in records(response):
241
+ event = _decode(record)
242
+ if event is None:
243
+ continue
244
+ yield event
245
+ if not isinstance(event, DeltaEvent):
246
+ return
247
+ yield ErrorEvent("stream_ended", "The stream ended before its final event.")
248
+ except (TimeoutError, socket.timeout):
249
+ raise CogniCloudError(0, "timeout", "The stream timed out.") from None
250
+ except OSError:
251
+ raise CogniCloudError(0, "network_error", "The stream was interrupted.") from None
252
+ finally:
253
+ response.close()
254
+
255
+
256
+ def _decode(record: Record) -> Optional[StreamEvent]:
257
+ """Events the gateway does not send are ignored, so a newer gateway can add events without breaking this client."""
258
+ if record.event == "delta":
259
+ return DeltaEvent(json.loads(record.data)["text"])
260
+ if record.event == "done":
261
+ return DoneEvent(ResponseResult.from_json(json.loads(record.data)))
262
+ if record.event == "error":
263
+ error = json.loads(record.data)
264
+ return ErrorEvent(error["code"], error["message"])
265
+ return None
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class CogniCloudError(Exception):
5
+ """A failed request. `status` is the HTTP status (0 when no answer came back); `code` is CogniCloud's error code."""
6
+
7
+ def __init__(self, status: int, code: str, message: str) -> None:
8
+ super().__init__(message)
9
+ self.status = status
10
+ self.code = code
11
+ self.message = message
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: cognicloud
3
+ Version: 0.9.0
4
+ Summary: CogniCloud SDK: browser sessions for agents, and the platform API.
5
+ License: LicenseRef-Proprietary
6
+ Project-URL: Source, https://github.com/Flippico/cognicloud-python-sdk
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # cognicloud
11
+
12
+ Use CogniCloud from Python. Version 0.4.0 covers browser sessions for agents; the other products come later. Same behaviour as [`@cognicloud/sdk`](https://github.com/Flippico/cognicloud-js-sdk) and [`cognicloud-sdk`](https://github.com/Flippico/cognicloud-rust-sdk).
13
+
14
+ ## Install
15
+
16
+ Python 3.10 or newer. No dependencies.
17
+
18
+ ```bash
19
+ pip install "git+https://github.com/Flippico/cognicloud-python-sdk.git"
20
+ ```
21
+
22
+ ## Browser sessions
23
+
24
+ ```python
25
+ from cognicloud import CogniCloud
26
+
27
+ cc = CogniCloud(amadeus_token)
28
+ session = cc.browser.sessions.create(timeout_minutes=15)
29
+ print(session.cdp_url) # connect Playwright, Puppeteer or any CDP client here
30
+ cc.browser.sessions.stop(session.id)
31
+ ```
32
+
33
+ - `timeout_minutes` is 1 to 240 and defaults to 15.
34
+ - Closing the CDP connection does not stop a session; call `sessions.stop`.
35
+ - `status` is `active` or `stopped`; `cdp_url` and `live_url` are `None` once stopped. `live_url` is a page where a person can watch and take over the browser.
36
+ - `sessions.list()` returns `items` and `concurrency` (`active`, `limit`). A start beyond the limit raises `CogniCloudError` with `status == 429` and `code == "concurrency_limit"`.
37
+ - Proxy countries, profiles, screen sizes, captcha solving and recording are not available yet. `browser.countries()` and `browser.profiles()` return empty lists.
38
+
39
+ ## Call an endpoint
40
+
41
+ ```python
42
+ import os
43
+ from cognicloud import EndpointClient, ResponsesRequest, McpTool, EmbeddingsRequest, DeltaEvent, DoneEvent, ErrorEvent
44
+
45
+ endpoint = EndpointClient(os.environ["COGNICLOUD_KEY"], endpoint_id=os.environ["COGNICLOUD_ENDPOINT_ID"])
46
+
47
+ answer = endpoint.responses(
48
+ ResponsesRequest(
49
+ input="Is this invoice valid?",
50
+ instructions="Check invoices.",
51
+ tools=[McpTool("ksef", allowed_tools=["validate_invoice_xml"])],
52
+ tool_choice="required",
53
+ )
54
+ )
55
+ next_answer = endpoint.responses(ResponsesRequest("And this one?", previous_response_id=answer.response_id))
56
+
57
+ vectors = endpoint.embeddings(EmbeddingsRequest(["first text", "second text"], dimensions=256))
58
+ ```
59
+
60
+ `McpTool` names an MCP server with `server_url` and `bearer_token`. The gateway checks the address (https, with no credentials or fragment) and passes both to the provider. A tool with only `server_label` names a server the gateway is configured with. The answer's `response_id` continues the conversation on the same endpoint, and `output_items` holds the tool calls. `embeddings` needs an endpoint whose deployment is an embedding model, and returns one vector per input.
61
+
62
+ ## Streaming
63
+
64
+ ```python
65
+ for event in endpoint.stream({"messages": [{"role": "user", "content": "Write a haiku."}]}):
66
+ if isinstance(event, DeltaEvent):
67
+ print(event.text, end="")
68
+ elif isinstance(event, DoneEvent):
69
+ print(f"\n{event.answer.usage.total_tokens} tokens")
70
+ elif isinstance(event, ErrorEvent):
71
+ print(f"{event.code}: {event.message}")
72
+ ```
73
+
74
+ `ResponsesRequest(extra={...})` sends any other field to the provider unchanged, such as `seed` or `reasoning`. The gateway refuses the fields it sets itself, such as `n` or `store`.
75
+
76
+ `stream` opens the call before it returns, so a refusal raises immediately, before any event. Each event is a `DeltaEvent` (`text`), then one `DoneEvent` (`answer`), or one `ErrorEvent` (`code`, `message`). Streams are not retried and have no total timeout; the gateway ends them at five minutes. Stop early by closing the iterator; the gateway still settles the call.
77
+
78
+ ## Errors
79
+
80
+ Every failed request raises `CogniCloudError` with `status` (the HTTP status, `0` when no answer came back), `code` (CogniCloud's stable error code, or a local one such as `timeout` or `network_error`) and `message`.
81
+
82
+ ## Configuration
83
+
84
+ | Option | Default | |
85
+ | --- | --- | --- |
86
+ | `token` | required | The signed-in person's Amadeus session token. |
87
+ | `tenant` | `"cognicloud"` | The workspace, sent as `tenant_id`. The platform workspace is open to every signed-in user. |
88
+ | `locale` | `"en"` | `Accept-Language` for localised responses. |
89
+ | `timeout` | `30.0` | Seconds per request. |
90
+ | `max_retries` | `2` | For `429` and `5xx`, on reads only. Starts and stops are never retried. |
91
+ | `browser_base_url` | `https://cognicloud.flippi.co/api/v1/browser` | Point at another environment. |
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ PYTHONPATH=src python -m unittest discover -s tests
97
+ ```
98
+
99
+ The tests run against a local HTTP stub and need no network access.
100
+
101
+ See [CHANGELOG.md](./CHANGELOG.md).
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/cognicloud/__init__.py
4
+ src/cognicloud/_http.py
5
+ src/cognicloud/_sse.py
6
+ src/cognicloud/browser.py
7
+ src/cognicloud/client.py
8
+ src/cognicloud/endpoint.py
9
+ src/cognicloud/errors.py
10
+ src/cognicloud.egg-info/PKG-INFO
11
+ src/cognicloud.egg-info/SOURCES.txt
12
+ src/cognicloud.egg-info/dependency_links.txt
13
+ src/cognicloud.egg-info/top_level.txt
14
+ tests/test_browser.py
15
+ tests/test_endpoint.py
@@ -0,0 +1 @@
1
+ cognicloud
@@ -0,0 +1,137 @@
1
+ import json
2
+ import threading
3
+ import unittest
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+
6
+ from cognicloud import CogniCloud, CogniCloudError
7
+
8
+ SESSION = {
9
+ "id": "b903c5d0",
10
+ "status": "active",
11
+ "cdpUrl": "wss://cognicloud.flippi.co/api/v1/browser/sessions/b903c5d0/cdp?token=t",
12
+ "liveUrl": "https://cognicloud.flippi.co/api/v1/browser/sessions/b903c5d0/live?token=t",
13
+ "profileId": None,
14
+ "proxyCountryCode": None,
15
+ "solveCaptchas": False,
16
+ "recording": False,
17
+ "screen": None,
18
+ "timeoutAt": "2026-10-22T10:15:00Z",
19
+ "startedAt": "2026-10-22T10:00:00Z",
20
+ "finishedAt": None,
21
+ }
22
+
23
+
24
+ class Stub(BaseHTTPRequestHandler):
25
+ """Answers from `routes[(method, path)]` as (status, body) and records every request."""
26
+
27
+ routes: dict = {}
28
+ seen: list = []
29
+
30
+ def _answer(self):
31
+ length = int(self.headers.get("content-length", 0))
32
+ body = self.rfile.read(length) if length else b""
33
+ Stub.seen.append(
34
+ {
35
+ "method": self.command,
36
+ "path": self.path,
37
+ "headers": {key.lower(): value for key, value in self.headers.items()},
38
+ "body": json.loads(body) if body else None,
39
+ }
40
+ )
41
+ status, payload = Stub.routes.get((self.command, self.path.split("?")[0]), (404, {"error": "not_found", "message": "No route matches this path."}))
42
+ raw = b"" if payload is None else json.dumps(payload).encode()
43
+ self.send_response(status)
44
+ self.send_header("content-type", "application/json")
45
+ self.send_header("content-length", str(len(raw)))
46
+ self.end_headers()
47
+ self.wfile.write(raw)
48
+
49
+ do_GET = _answer
50
+ do_POST = _answer
51
+ do_DELETE = _answer
52
+
53
+ def log_message(self, *args):
54
+ pass
55
+
56
+
57
+ class BrowserTests(unittest.TestCase):
58
+ def setUp(self):
59
+ Stub.routes = {}
60
+ Stub.seen = []
61
+ self.server = HTTPServer(("127.0.0.1", 0), Stub)
62
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
63
+ self.base = f"http://127.0.0.1:{self.server.server_address[1]}/v1/browser"
64
+
65
+ def tearDown(self):
66
+ self.server.shutdown()
67
+ self.server.server_close()
68
+
69
+ def client(self, **options):
70
+ return CogniCloud("amadeus-token", tenant="acme", browser_base_url=self.base, **options)
71
+
72
+ def test_creates_a_session_at_the_browser_base_with_the_session_headers(self):
73
+ Stub.routes[("POST", "/v1/browser/sessions")] = (201, SESSION)
74
+ session = self.client().browser.sessions.create(timeout_minutes=15)
75
+ self.assertEqual(session.id, "b903c5d0")
76
+ self.assertEqual(session.status, "active")
77
+ self.assertTrue(session.cdp_url.startswith("wss://"))
78
+ request = Stub.seen[0]
79
+ self.assertEqual(request["body"], {"timeoutMinutes": 15})
80
+ self.assertEqual(request["headers"]["authorization"], "Bearer amadeus-token")
81
+ self.assertEqual(request["headers"]["tenant_id"], "acme")
82
+
83
+ def test_lists_reads_and_stops_sessions(self):
84
+ stopped = dict(SESSION, status="stopped", cdpUrl=None, liveUrl=None, finishedAt="2026-10-22T10:05:00Z")
85
+ Stub.routes[("GET", "/v1/browser/sessions")] = (200, {"items": [SESSION], "concurrency": {"active": 1, "limit": 2}})
86
+ Stub.routes[("GET", "/v1/browser/sessions/b903c5d0")] = (200, SESSION)
87
+ Stub.routes[("POST", "/v1/browser/sessions/b903c5d0/stop")] = (200, stopped)
88
+ browser = self.client().browser
89
+ listed = browser.sessions.list()
90
+ self.assertEqual((listed.concurrency.active, listed.concurrency.limit), (1, 2))
91
+ self.assertEqual(browser.sessions.get("b903c5d0").status, "active")
92
+ after = browser.sessions.stop("b903c5d0")
93
+ self.assertEqual(after.status, "stopped")
94
+ self.assertIsNone(after.cdp_url)
95
+ self.assertEqual(Stub.seen[2]["method"], "POST")
96
+
97
+ def test_raises_the_service_error_code_when_the_workspace_is_full(self):
98
+ Stub.routes[("POST", "/v1/browser/sessions")] = (
99
+ 429,
100
+ {"error": "concurrency_limit", "message": "Stop a session before starting another."},
101
+ )
102
+ with self.assertRaises(CogniCloudError) as caught:
103
+ self.client().browser.sessions.create()
104
+ self.assertEqual(caught.exception.status, 429)
105
+ self.assertEqual(caught.exception.code, "concurrency_limit")
106
+
107
+ def test_countries_and_profiles_are_empty_until_they_exist(self):
108
+ Stub.routes[("GET", "/v1/browser/countries")] = (200, [])
109
+ Stub.routes[("GET", "/v1/browser/profiles")] = (200, [])
110
+ browser = self.client().browser
111
+ self.assertEqual(browser.countries(), [])
112
+ self.assertEqual(browser.profiles(), [])
113
+
114
+ def test_retries_reads_but_not_starts(self):
115
+ Stub.routes[("GET", "/v1/browser/sessions")] = (503, {"error": "service_unavailable", "message": "later"})
116
+ with self.assertRaises(CogniCloudError):
117
+ self.client(max_retries=2).browser.sessions.list()
118
+ self.assertEqual(len(Stub.seen), 3)
119
+ Stub.seen = []
120
+ Stub.routes[("POST", "/v1/browser/sessions")] = (503, {"error": "service_unavailable", "message": "later"})
121
+ with self.assertRaises(CogniCloudError):
122
+ self.client(max_retries=2).browser.sessions.create()
123
+ self.assertEqual(len(Stub.seen), 1)
124
+
125
+ def test_reports_an_unreachable_service_as_a_network_error(self):
126
+ probe = HTTPServer(("127.0.0.1", 0), Stub)
127
+ closed_port = probe.server_address[1]
128
+ probe.server_close()
129
+ client = CogniCloud("amadeus-token", browser_base_url=f"http://127.0.0.1:{closed_port}/v1/browser", max_retries=0)
130
+ with self.assertRaises(CogniCloudError) as caught:
131
+ client.browser.sessions.list()
132
+ self.assertEqual(caught.exception.status, 0)
133
+ self.assertEqual(caught.exception.code, "network_error")
134
+
135
+
136
+ if __name__ == "__main__":
137
+ unittest.main()
@@ -0,0 +1,210 @@
1
+ import json
2
+ import threading
3
+ import unittest
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+
6
+ from cognicloud import (
7
+ CogniCloudError,
8
+ DeltaEvent,
9
+ DoneEvent,
10
+ EmbeddingsRequest,
11
+ EndpointClient,
12
+ ErrorEvent,
13
+ McpTool,
14
+ ResponsesRequest,
15
+ )
16
+
17
+ ANSWER = {
18
+ "id": "0c4e0e4a-0000-0000-0000-000000000000",
19
+ "model": "gpt-5-mini",
20
+ "output": "Valid.",
21
+ "finishReason": "stop",
22
+ "usage": {"inputTokens": 10, "outputTokens": 3, "totalTokens": 13},
23
+ "responseId": "resp_1",
24
+ "outputItems": [{"type": "mcp_call", "name": "validate_invoice_xml"}],
25
+ }
26
+ INVOKE_PATH = "/invoke/636f676e69636c6f7564/e1"
27
+
28
+
29
+ class Stub(BaseHTTPRequestHandler):
30
+ """Answers from `routes[path]` as (status, content_type, body) and records each request."""
31
+
32
+ routes: dict = {}
33
+ seen: list = []
34
+
35
+ def do_POST(self):
36
+ length = int(self.headers.get("content-length", 0))
37
+ body = json.loads(self.rfile.read(length)) if length else None
38
+ Stub.seen.append(
39
+ {"path": self.path, "headers": {key.lower(): value for key, value in self.headers.items()}, "body": body}
40
+ )
41
+ status, content_type, raw = Stub.routes[self.path]
42
+ self.send_response(status)
43
+ self.send_header("content-type", content_type)
44
+ self.send_header("content-length", str(len(raw)))
45
+ self.end_headers()
46
+ self.wfile.write(raw)
47
+
48
+ def log_message(self, *args):
49
+ pass
50
+
51
+
52
+ def json_route(status, payload):
53
+ return (status, "application/json", json.dumps(payload).encode())
54
+
55
+
56
+ def sse_route(text):
57
+ return (200, "text/event-stream", text.encode())
58
+
59
+
60
+ class EndpointTests(unittest.TestCase):
61
+ @classmethod
62
+ def setUpClass(cls):
63
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
64
+ cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
65
+ cls.thread.start()
66
+ cls.base = f"http://127.0.0.1:{cls.server.server_address[1]}"
67
+
68
+ @classmethod
69
+ def tearDownClass(cls):
70
+ cls.server.shutdown()
71
+ cls.server.server_close()
72
+
73
+ def setUp(self):
74
+ Stub.routes = {}
75
+ Stub.seen = []
76
+
77
+ def client(self, **options):
78
+ return EndpointClient("cc_live_secret", endpoint_id="e1", base_url=self.base, **options)
79
+
80
+ def test_invoke_sends_the_credential_and_returns_the_answer(self):
81
+ Stub.routes[INVOKE_PATH] = json_route(200, ANSWER)
82
+ answer = self.client().invoke({"messages": [{"role": "user", "content": "Hi"}]})
83
+ self.assertEqual(answer.output, "Valid.")
84
+ self.assertEqual(answer.usage.total_tokens, 13)
85
+ self.assertEqual(Stub.seen[0]["headers"]["authorization"], "Bearer cc_live_secret")
86
+
87
+ def test_responses_sends_mcp_tools_by_label(self):
88
+ Stub.routes[INVOKE_PATH] = json_route(200, ANSWER)
89
+ request = ResponsesRequest(
90
+ input="Check this invoice.",
91
+ instructions="Be brief.",
92
+ tools=[McpTool("ksef", allowed_tools=["validate_invoice_xml"])],
93
+ tool_choice="required",
94
+ )
95
+ answer = self.client().responses(request)
96
+ self.assertEqual(answer.response_id, "resp_1")
97
+ self.assertEqual(len(answer.output_items), 1)
98
+ self.assertEqual(
99
+ Stub.seen[0]["body"],
100
+ {
101
+ "input": "Check this invoice.",
102
+ "instructions": "Be brief.",
103
+ "tools": [{"type": "mcp", "server_label": "ksef", "allowed_tools": ["validate_invoice_xml"]}],
104
+ "tool_choice": "required",
105
+ },
106
+ )
107
+
108
+ def test_a_caller_server_and_its_token_are_sent_and_the_token_is_hidden_in_repr(self):
109
+ Stub.routes[INVOKE_PATH] = json_route(200, ANSWER)
110
+ tool = McpTool("ksef", allowed_tools=["validate_invoice_xml"], server_url="https://ksef-api.flippi.co/mcp/v1", bearer_token="abc123")
111
+ self.client().responses(ResponsesRequest(input="Check this invoice.", tools=[tool]))
112
+ self.assertEqual(
113
+ Stub.seen[0]["body"]["tools"],
114
+ [
115
+ {
116
+ "type": "mcp",
117
+ "server_label": "ksef",
118
+ "server_url": "https://ksef-api.flippi.co/mcp/v1",
119
+ "headers": {"Authorization": "Bearer abc123"},
120
+ "allowed_tools": ["validate_invoice_xml"],
121
+ }
122
+ ],
123
+ )
124
+ self.assertNotIn("abc123", repr(tool))
125
+
126
+ def test_extra_fields_are_sent_at_the_top_level(self):
127
+ Stub.routes[INVOKE_PATH] = json_route(200, ANSWER)
128
+ self.client().responses(ResponsesRequest(input="Hi", extra={"seed": 7, "reasoning": {"effort": "low"}}))
129
+ self.assertEqual(Stub.seen[0]["body"], {"input": "Hi", "seed": 7, "reasoning": {"effort": "low"}})
130
+
131
+ def test_responses_continues_with_previous_response_id(self):
132
+ Stub.routes[INVOKE_PATH] = json_route(200, {**ANSWER, "responseId": "resp_2"})
133
+ answer = self.client().responses(ResponsesRequest(input="And this one?", previous_response_id="resp_1"))
134
+ self.assertEqual(answer.response_id, "resp_2")
135
+ self.assertEqual(Stub.seen[0]["body"], {"input": "And this one?", "previous_response_id": "resp_1"})
136
+
137
+ def test_embeddings_return_one_vector_per_input(self):
138
+ Stub.routes[INVOKE_PATH + "/embeddings"] = json_route(
139
+ 200,
140
+ {
141
+ "id": "x",
142
+ "model": "embed-small",
143
+ "data": [{"index": 0, "embedding": [0.1, 0.2]}, {"index": 1, "embedding": [0.3, 0.4]}],
144
+ "usage": {"inputTokens": 12, "outputTokens": 0, "totalTokens": 12},
145
+ },
146
+ )
147
+ result = self.client().embeddings(EmbeddingsRequest(["first", "second"], dimensions=256))
148
+ self.assertEqual(result.data[1].embedding, [0.3, 0.4])
149
+ self.assertEqual(Stub.seen[0]["body"], {"input": ["first", "second"], "dimensions": 256})
150
+
151
+ def test_invoke_is_never_retried_on_server_errors(self):
152
+ Stub.routes[INVOKE_PATH] = json_route(503, {"error": "gateway_unavailable", "message": "Try again."})
153
+ with self.assertRaises(CogniCloudError) as caught:
154
+ self.client(max_retries=3).invoke({"input": "Hi"})
155
+ self.assertEqual((caught.exception.status, caught.exception.code), (503, "gateway_unavailable"))
156
+ self.assertEqual(len(Stub.seen), 1)
157
+
158
+ def test_stream_yields_deltas_then_the_done_answer(self):
159
+ body = (
160
+ 'event: delta\ndata: {"text":"Hel"}\n\n'
161
+ 'event: delta\r\ndata: {"text":"lo"}\r\n\r\n'
162
+ f"event: done\ndata: {json.dumps({**ANSWER, 'output': 'Hello'})}\n\n"
163
+ )
164
+ Stub.routes[INVOKE_PATH] = sse_route(body)
165
+ events = list(self.client().stream({"messages": [{"role": "user", "content": "hi"}]}))
166
+ self.assertEqual(events[0], DeltaEvent("Hel"))
167
+ self.assertEqual(events[1], DeltaEvent("lo"))
168
+ self.assertIsInstance(events[2], DoneEvent)
169
+ self.assertEqual(events[2].answer.output, "Hello")
170
+ self.assertEqual(len(events), 3)
171
+ self.assertEqual(Stub.seen[0]["body"], {"messages": [{"role": "user", "content": "hi"}], "stream": True})
172
+ self.assertEqual(Stub.seen[0]["headers"]["accept"], "text/event-stream")
173
+
174
+ def test_stream_yields_a_provider_error_and_stops(self):
175
+ Stub.routes[INVOKE_PATH] = sse_route(
176
+ 'event: delta\ndata: {"text":"Hi"}\n\n'
177
+ 'event: error\ndata: {"code":"upstream_failure","message":"The provider did not finish the answer."}\n\n'
178
+ )
179
+ events = list(self.client().stream_responses(ResponsesRequest("x")))
180
+ self.assertEqual(events[1], ErrorEvent("upstream_failure", "The provider did not finish the answer."))
181
+ self.assertEqual(len(events), 2)
182
+
183
+ def test_stream_reports_stream_ended_when_it_closes_early(self):
184
+ Stub.routes[INVOKE_PATH] = sse_route('event: delta\ndata: {"text":"Hi"}\n\n')
185
+ events = list(self.client().stream({"input": "x"}))
186
+ self.assertEqual(events[1], ErrorEvent("stream_ended", "The stream ended before its final event."))
187
+
188
+ def test_a_refused_stream_raises_before_any_event(self):
189
+ Stub.routes[INVOKE_PATH] = json_route(
190
+ 422, {"error": "invalid_request", "message": "Streaming needs an API version of 2024-10-21 or later."}
191
+ )
192
+ with self.assertRaises(CogniCloudError) as caught:
193
+ self.client().stream({"input": "x"})
194
+ self.assertEqual((caught.exception.status, caught.exception.code), (422, "invalid_request"))
195
+
196
+ def test_a_stream_is_never_retried(self):
197
+ Stub.routes[INVOKE_PATH] = json_route(503, {"error": "gateway_unavailable", "message": "Try again."})
198
+ with self.assertRaises(CogniCloudError):
199
+ self.client(max_retries=3).stream({"input": "x"})
200
+ self.assertEqual(len(Stub.seen), 1)
201
+
202
+ def test_an_endpoint_needs_exactly_one_address(self):
203
+ with self.assertRaises(ValueError):
204
+ EndpointClient("k")
205
+ with self.assertRaises(ValueError):
206
+ EndpointClient("k", endpoint_id="e1", url=self.base + INVOKE_PATH)
207
+
208
+
209
+ if __name__ == "__main__":
210
+ unittest.main()