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.
- cognicloud-0.9.0/PKG-INFO +101 -0
- cognicloud-0.9.0/README.md +92 -0
- cognicloud-0.9.0/pyproject.toml +18 -0
- cognicloud-0.9.0/setup.cfg +4 -0
- cognicloud-0.9.0/src/cognicloud/__init__.py +52 -0
- cognicloud-0.9.0/src/cognicloud/_http.py +104 -0
- cognicloud-0.9.0/src/cognicloud/_sse.py +33 -0
- cognicloud-0.9.0/src/cognicloud/browser.py +141 -0
- cognicloud-0.9.0/src/cognicloud/client.py +31 -0
- cognicloud-0.9.0/src/cognicloud/endpoint.py +265 -0
- cognicloud-0.9.0/src/cognicloud/errors.py +11 -0
- cognicloud-0.9.0/src/cognicloud.egg-info/PKG-INFO +101 -0
- cognicloud-0.9.0/src/cognicloud.egg-info/SOURCES.txt +15 -0
- cognicloud-0.9.0/src/cognicloud.egg-info/dependency_links.txt +1 -0
- cognicloud-0.9.0/src/cognicloud.egg-info/top_level.txt +1 -0
- cognicloud-0.9.0/tests/test_browser.py +137 -0
- cognicloud-0.9.0/tests/test_endpoint.py +210 -0
|
@@ -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,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
|
+
|
|
@@ -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()
|