cyris 0.1.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.
- cyris-0.1.0/LICENSE +21 -0
- cyris-0.1.0/PKG-INFO +77 -0
- cyris-0.1.0/README.md +40 -0
- cyris-0.1.0/cyris/__init__.py +5 -0
- cyris-0.1.0/cyris/buffer.py +126 -0
- cyris-0.1.0/cyris/cli.py +202 -0
- cyris-0.1.0/cyris/core.py +216 -0
- cyris-0.1.0/cyris/credentials.py +51 -0
- cyris-0.1.0/cyris/fingerprint.py +30 -0
- cyris-0.1.0/cyris/patchers.py +557 -0
- cyris-0.1.0/cyris/phi.py +62 -0
- cyris-0.1.0/cyris/session.py +49 -0
- cyris-0.1.0/cyris/version.py +1 -0
- cyris-0.1.0/cyris.egg-info/PKG-INFO +77 -0
- cyris-0.1.0/cyris.egg-info/SOURCES.txt +19 -0
- cyris-0.1.0/cyris.egg-info/dependency_links.txt +1 -0
- cyris-0.1.0/cyris.egg-info/entry_points.txt +2 -0
- cyris-0.1.0/cyris.egg-info/requires.txt +11 -0
- cyris-0.1.0/cyris.egg-info/top_level.txt +1 -0
- cyris-0.1.0/pyproject.toml +61 -0
- cyris-0.1.0/setup.cfg +4 -0
cyris-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cyris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
cyris-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cyris
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The system of record for AI agent decisions. Log every LLM call your agents make, hash-chain the audit trail, and answer compliance questionnaires from real data.
|
|
5
|
+
Author-email: Cyris <hello@cyrisai.dev>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://cyrisai.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/ultimatem7/Cyris
|
|
9
|
+
Project-URL: Issues, https://github.com/ultimatem7/Cyris/issues
|
|
10
|
+
Keywords: ai,audit,compliance,hipaa,llm,observability,openai,anthropic,agents,monitoring
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Monitoring
|
|
24
|
+
Classifier: Topic :: System :: Logging
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: requests>=2.20.0
|
|
29
|
+
Provides-Extra: openai
|
|
30
|
+
Requires-Dist: openai>=1.0.0; extra == "openai"
|
|
31
|
+
Provides-Extra: anthropic
|
|
32
|
+
Requires-Dist: anthropic>=0.18.0; extra == "anthropic"
|
|
33
|
+
Provides-Extra: all
|
|
34
|
+
Requires-Dist: openai>=1.0.0; extra == "all"
|
|
35
|
+
Requires-Dist: anthropic>=0.18.0; extra == "all"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# cyris
|
|
39
|
+
|
|
40
|
+
The system of record for AI agent decisions.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install cyris
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import cyris
|
|
52
|
+
cyris.init(api_key="your-key")
|
|
53
|
+
# That's it. Every OpenAI/Anthropic call is now logged.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Manual logging
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
cyris.log_call(provider="openai", model="gpt-4o", input_text="Hello", output_text="Hi there")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Sessions
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
with cyris.session("my-workflow") as sid:
|
|
66
|
+
# all calls inside this block share the same session_id
|
|
67
|
+
...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## CLI
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cyris login # authenticate via browser
|
|
74
|
+
cyris whoami # show current identity
|
|
75
|
+
cyris status # check API connectivity
|
|
76
|
+
cyris logout # remove saved credentials
|
|
77
|
+
```
|
cyris-0.1.0/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# cyris
|
|
2
|
+
|
|
3
|
+
The system of record for AI agent decisions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cyris
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import cyris
|
|
15
|
+
cyris.init(api_key="your-key")
|
|
16
|
+
# That's it. Every OpenAI/Anthropic call is now logged.
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Manual logging
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
cyris.log_call(provider="openai", model="gpt-4o", input_text="Hello", output_text="Hi there")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Sessions
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
with cyris.session("my-workflow") as sid:
|
|
29
|
+
# all calls inside this block share the same session_id
|
|
30
|
+
...
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## CLI
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cyris login # authenticate via browser
|
|
37
|
+
cyris whoami # show current identity
|
|
38
|
+
cyris status # check API connectivity
|
|
39
|
+
cyris logout # remove saved credentials
|
|
40
|
+
```
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thread-safe event buffer with background flushing.
|
|
3
|
+
|
|
4
|
+
Events are queued in memory and periodically POSTed to the Cyris
|
|
5
|
+
``/ingest-batch`` endpoint by a daemon thread. The design guarantees:
|
|
6
|
+
|
|
7
|
+
* **No perceptible latency** -- callers return immediately after enqueue.
|
|
8
|
+
* **No data loss on exit** -- ``atexit`` flushes remaining events.
|
|
9
|
+
* **Silent failures** -- network errors go to *stderr*, never raise.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import atexit
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
_queue: List[Dict[str, Any]] = []
|
|
20
|
+
_lock = threading.Lock()
|
|
21
|
+
_flush_event = threading.Event()
|
|
22
|
+
|
|
23
|
+
_api_url: Optional[str] = None
|
|
24
|
+
_api_key: Optional[str] = None
|
|
25
|
+
_thread: Optional[threading.Thread] = None
|
|
26
|
+
_shutdown_flag = threading.Event()
|
|
27
|
+
|
|
28
|
+
_FLUSH_INTERVAL_S = 5
|
|
29
|
+
_FLUSH_BATCH_SIZE = 10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------- #
|
|
33
|
+
# Public API #
|
|
34
|
+
# ---------------------------------------------------------------------- #
|
|
35
|
+
|
|
36
|
+
def configure(api_url: str, api_key: str) -> None:
|
|
37
|
+
"""Set the target endpoint and API key. Called once from ``init()``."""
|
|
38
|
+
global _api_url, _api_key
|
|
39
|
+
_api_url = api_url.rstrip("/")
|
|
40
|
+
_api_key = api_key
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def start() -> None:
|
|
44
|
+
"""Spin up the background flush thread (idempotent)."""
|
|
45
|
+
global _thread
|
|
46
|
+
if _thread is not None and _thread.is_alive():
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
_shutdown_flag.clear()
|
|
50
|
+
_thread = threading.Thread(target=_flush_loop, daemon=True, name="cyris-buffer")
|
|
51
|
+
_thread.start()
|
|
52
|
+
atexit.register(shutdown)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_event(event: Dict[str, Any]) -> None:
|
|
56
|
+
"""Enqueue an event dict for later transmission."""
|
|
57
|
+
with _lock:
|
|
58
|
+
_queue.append(event)
|
|
59
|
+
if len(_queue) >= _FLUSH_BATCH_SIZE:
|
|
60
|
+
_flush_event.set()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def flush() -> None:
|
|
64
|
+
"""Immediately flush the current queue (best-effort)."""
|
|
65
|
+
_do_flush()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def shutdown() -> None:
|
|
69
|
+
"""Signal the background thread to stop and flush remaining events."""
|
|
70
|
+
_shutdown_flag.set()
|
|
71
|
+
_flush_event.set() # wake the thread so it exits promptly
|
|
72
|
+
if _thread is not None and _thread.is_alive():
|
|
73
|
+
_thread.join(timeout=5)
|
|
74
|
+
_do_flush()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------- #
|
|
78
|
+
# Internals #
|
|
79
|
+
# ---------------------------------------------------------------------- #
|
|
80
|
+
|
|
81
|
+
def _flush_loop() -> None:
|
|
82
|
+
"""Background loop: flush every *_FLUSH_INTERVAL_S* or on signal."""
|
|
83
|
+
while not _shutdown_flag.is_set():
|
|
84
|
+
_flush_event.wait(timeout=_FLUSH_INTERVAL_S)
|
|
85
|
+
_flush_event.clear()
|
|
86
|
+
_do_flush()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _do_flush() -> None:
|
|
90
|
+
"""Drain the queue and POST to the ingest endpoint."""
|
|
91
|
+
with _lock:
|
|
92
|
+
if not _queue:
|
|
93
|
+
return
|
|
94
|
+
batch = list(_queue)
|
|
95
|
+
_queue.clear()
|
|
96
|
+
|
|
97
|
+
if not _api_url or not _api_key:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
import requests # deferred so import-time stays fast
|
|
102
|
+
|
|
103
|
+
url = f"{_api_url}/ingest-batch"
|
|
104
|
+
headers = {
|
|
105
|
+
"Authorization": f"Bearer {_api_key}",
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
}
|
|
108
|
+
# Server expects { "events": [...] }. Sending a raw list returns 400.
|
|
109
|
+
payload = json.dumps({"events": batch})
|
|
110
|
+
resp = requests.post(url, data=payload, headers=headers, timeout=10)
|
|
111
|
+
|
|
112
|
+
# Surface non-2xx in stderr so users notice silently-dropped events.
|
|
113
|
+
if not (200 <= resp.status_code < 300):
|
|
114
|
+
try:
|
|
115
|
+
print(
|
|
116
|
+
f"[cyris] flush rejected: HTTP {resp.status_code} {resp.text[:200]}",
|
|
117
|
+
file=sys.stderr,
|
|
118
|
+
)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
except Exception as exc: # noqa: BLE001 -- intentionally broad
|
|
122
|
+
# Silent failure: never crash the host application.
|
|
123
|
+
try:
|
|
124
|
+
print(f"[cyris] flush error: {exc}", file=sys.stderr)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
cyris-0.1.0/cyris/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for the ``cyris`` command.
|
|
3
|
+
|
|
4
|
+
Subcommands
|
|
5
|
+
-----------
|
|
6
|
+
cyris login -- authenticate via browser OAuth flow
|
|
7
|
+
cyris logout -- remove saved credentials
|
|
8
|
+
cyris whoami -- display the current identity
|
|
9
|
+
cyris status -- check API connectivity
|
|
10
|
+
cyris --version -- print package version
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import http.server
|
|
15
|
+
import json
|
|
16
|
+
import secrets
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import webbrowser
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from .credentials import delete_credentials, load_credentials, save_credentials
|
|
24
|
+
from .version import __version__
|
|
25
|
+
|
|
26
|
+
_DEFAULT_API_URL = "https://cyrisai.dev/api/v1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ====================================================================== #
|
|
30
|
+
# CLI dispatcher #
|
|
31
|
+
# ====================================================================== #
|
|
32
|
+
|
|
33
|
+
def main(argv: Optional[list] = None) -> None:
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="cyris",
|
|
36
|
+
description="Cyris -- the system of record for AI agent decisions.",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--version", "-V",
|
|
40
|
+
action="version",
|
|
41
|
+
version=f"cyris {__version__}",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
sub = parser.add_subparsers(dest="command")
|
|
45
|
+
|
|
46
|
+
# login
|
|
47
|
+
login_p = sub.add_parser("login", help="Authenticate with Cyris")
|
|
48
|
+
login_p.add_argument("--api-url", default=_DEFAULT_API_URL, help="API base URL")
|
|
49
|
+
|
|
50
|
+
# logout
|
|
51
|
+
sub.add_parser("logout", help="Remove saved credentials")
|
|
52
|
+
|
|
53
|
+
# whoami
|
|
54
|
+
sub.add_parser("whoami", help="Show current identity")
|
|
55
|
+
|
|
56
|
+
# status
|
|
57
|
+
status_p = sub.add_parser("status", help="Check API connectivity")
|
|
58
|
+
status_p.add_argument("--api-url", default=None, help="Override API URL")
|
|
59
|
+
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
if args.command == "login":
|
|
63
|
+
_cmd_login(args.api_url)
|
|
64
|
+
elif args.command == "logout":
|
|
65
|
+
_cmd_logout()
|
|
66
|
+
elif args.command == "whoami":
|
|
67
|
+
_cmd_whoami()
|
|
68
|
+
elif args.command == "status":
|
|
69
|
+
_cmd_status(args.api_url)
|
|
70
|
+
else:
|
|
71
|
+
parser.print_help()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ====================================================================== #
|
|
75
|
+
# Subcommand implementations #
|
|
76
|
+
# ====================================================================== #
|
|
77
|
+
|
|
78
|
+
def _cmd_login(api_url: str) -> None:
|
|
79
|
+
"""Open the browser for OAuth, start a local HTTP server for callback."""
|
|
80
|
+
api_url = api_url.rstrip("/")
|
|
81
|
+
state = secrets.token_urlsafe(32)
|
|
82
|
+
|
|
83
|
+
# Pick an ephemeral port for the callback server.
|
|
84
|
+
result: dict = {}
|
|
85
|
+
server_ready = threading.Event()
|
|
86
|
+
|
|
87
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
88
|
+
def do_GET(self) -> None: # noqa: N802
|
|
89
|
+
query = urllib.parse.urlparse(self.path).query
|
|
90
|
+
params = urllib.parse.parse_qs(query)
|
|
91
|
+
|
|
92
|
+
received_state = params.get("state", [None])[0]
|
|
93
|
+
api_key = params.get("api_key", [None])[0]
|
|
94
|
+
org_name = params.get("org_name", [""])[0]
|
|
95
|
+
email = params.get("email", [""])[0]
|
|
96
|
+
|
|
97
|
+
if received_state != state:
|
|
98
|
+
self.send_response(400)
|
|
99
|
+
self.end_headers()
|
|
100
|
+
self.wfile.write(b"State mismatch -- login aborted.")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
result["api_key"] = api_key
|
|
104
|
+
result["org_name"] = org_name
|
|
105
|
+
result["email"] = email
|
|
106
|
+
|
|
107
|
+
self.send_response(200)
|
|
108
|
+
self.send_header("Content-Type", "text/html")
|
|
109
|
+
self.end_headers()
|
|
110
|
+
self.wfile.write(
|
|
111
|
+
b"<html><body><h2>Login successful!</h2>"
|
|
112
|
+
b"<p>You can close this tab.</p></body></html>"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def log_message(self, fmt, *args) -> None: # noqa: ARG002
|
|
116
|
+
pass # silence server logs
|
|
117
|
+
|
|
118
|
+
server = http.server.HTTPServer(("127.0.0.1", 0), _CallbackHandler)
|
|
119
|
+
port = server.server_address[1]
|
|
120
|
+
|
|
121
|
+
def _serve() -> None:
|
|
122
|
+
server_ready.set()
|
|
123
|
+
server.handle_request() # single request then stop
|
|
124
|
+
|
|
125
|
+
t = threading.Thread(target=_serve, daemon=True)
|
|
126
|
+
t.start()
|
|
127
|
+
server_ready.wait()
|
|
128
|
+
|
|
129
|
+
callback = f"http://localhost:{port}"
|
|
130
|
+
login_url = f"{api_url}/auth/cli?callback={urllib.parse.quote(callback)}&state={state}"
|
|
131
|
+
|
|
132
|
+
print(f"Opening browser for login...\n{login_url}")
|
|
133
|
+
webbrowser.open(login_url)
|
|
134
|
+
|
|
135
|
+
t.join(timeout=120)
|
|
136
|
+
server.server_close()
|
|
137
|
+
|
|
138
|
+
if result.get("api_key"):
|
|
139
|
+
save_credentials(
|
|
140
|
+
api_key=result["api_key"],
|
|
141
|
+
api_url=api_url,
|
|
142
|
+
org_name=result.get("org_name", ""),
|
|
143
|
+
email=result.get("email", ""),
|
|
144
|
+
)
|
|
145
|
+
print(f"Logged in as {result.get('email', 'unknown')} ({result.get('org_name', '')})")
|
|
146
|
+
else:
|
|
147
|
+
print("Login failed or timed out.", file=sys.stderr)
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _cmd_logout() -> None:
|
|
152
|
+
delete_credentials()
|
|
153
|
+
print("Logged out. Credentials removed.")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cmd_whoami() -> None:
|
|
157
|
+
creds = load_credentials()
|
|
158
|
+
if not creds:
|
|
159
|
+
print("Not logged in. Run `cyris login` first.")
|
|
160
|
+
return
|
|
161
|
+
print(f"Email: {creds.get('email', '(not set)')}")
|
|
162
|
+
print(f"Org: {creds.get('org_name', '(not set)')}")
|
|
163
|
+
print(f"API URL: {creds.get('api_url', _DEFAULT_API_URL)}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _cmd_status(api_url_override: Optional[str]) -> None:
|
|
167
|
+
creds = load_credentials()
|
|
168
|
+
api_url = api_url_override or (creds and creds.get("api_url")) or _DEFAULT_API_URL
|
|
169
|
+
api_key = (creds and creds.get("api_key")) or None
|
|
170
|
+
|
|
171
|
+
if not api_key:
|
|
172
|
+
print("No API key found. Run `cyris login` first.", file=sys.stderr)
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
175
|
+
api_url = api_url.rstrip("/")
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
import requests
|
|
179
|
+
|
|
180
|
+
resp = requests.get(
|
|
181
|
+
f"{api_url}/agents",
|
|
182
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
183
|
+
timeout=10,
|
|
184
|
+
)
|
|
185
|
+
if resp.status_code == 200:
|
|
186
|
+
data = resp.json()
|
|
187
|
+
agents = data if isinstance(data, list) else data.get("agents", [])
|
|
188
|
+
print(f"Connected to {api_url}")
|
|
189
|
+
print(f"Agents: {len(agents)}")
|
|
190
|
+
else:
|
|
191
|
+
print(f"API returned HTTP {resp.status_code}", file=sys.stderr)
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
except ImportError:
|
|
194
|
+
print("The `requests` package is required. pip install requests", file=sys.stderr)
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
print(f"Connection failed: {exc}", file=sys.stderr)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
main()
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core initialisation and logging functions for the Cyris SDK.
|
|
3
|
+
|
|
4
|
+
This module exposes the four public helpers re-exported from ``cyris``:
|
|
5
|
+
|
|
6
|
+
cyris.init() -- configure the SDK
|
|
7
|
+
cyris.log_call() -- record an LLM invocation
|
|
8
|
+
cyris.log_override() -- record a human override
|
|
9
|
+
cyris.log_event() -- record an arbitrary audit event
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from . import buffer
|
|
20
|
+
from .credentials import load_credentials
|
|
21
|
+
from .fingerprint import generate_fingerprints
|
|
22
|
+
from .phi import detect_phi
|
|
23
|
+
from .session import get_current_session
|
|
24
|
+
|
|
25
|
+
# ------------------------------------------------------------------ #
|
|
26
|
+
# Module-level config (populated by ``init``) #
|
|
27
|
+
# ------------------------------------------------------------------ #
|
|
28
|
+
|
|
29
|
+
_config: Dict[str, Any] = {
|
|
30
|
+
"api_key": None,
|
|
31
|
+
"api_url": "https://cyrisai.dev/api/v1",
|
|
32
|
+
"agent_name": None,
|
|
33
|
+
"auto_patch": True,
|
|
34
|
+
"initialised": False,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_api_key(api_key: Optional[str]) -> Optional[str]:
|
|
39
|
+
"""Resolve the API key from (in order) parameter, env var, saved creds."""
|
|
40
|
+
if api_key:
|
|
41
|
+
return api_key
|
|
42
|
+
|
|
43
|
+
env_key = os.environ.get("CYRIS_API_KEY")
|
|
44
|
+
if env_key:
|
|
45
|
+
return env_key
|
|
46
|
+
|
|
47
|
+
creds = load_credentials()
|
|
48
|
+
if creds and creds.get("api_key"):
|
|
49
|
+
return creds["api_key"]
|
|
50
|
+
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------ #
|
|
55
|
+
# init #
|
|
56
|
+
# ------------------------------------------------------------------ #
|
|
57
|
+
|
|
58
|
+
def init(
|
|
59
|
+
api_key: Optional[str] = None,
|
|
60
|
+
api_url: Optional[str] = None,
|
|
61
|
+
agent_name: Optional[str] = None,
|
|
62
|
+
auto_patch: bool = True,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Initialise the Cyris SDK.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
api_key : str, optional
|
|
69
|
+
Cyris API key. Falls back to ``CYRIS_API_KEY`` env var, then to
|
|
70
|
+
saved credentials at ``~/.cyris/credentials``.
|
|
71
|
+
api_url : str, optional
|
|
72
|
+
Base URL of the Cyris API (default ``https://cyrisai.dev/api/v1``).
|
|
73
|
+
agent_name : str, optional
|
|
74
|
+
Logical name of the agent being instrumented.
|
|
75
|
+
auto_patch : bool
|
|
76
|
+
When ``True`` (default), monkey-patch OpenAI and Anthropic client
|
|
77
|
+
libraries so every call is automatically logged.
|
|
78
|
+
"""
|
|
79
|
+
resolved_key = _resolve_api_key(api_key)
|
|
80
|
+
if not resolved_key:
|
|
81
|
+
try:
|
|
82
|
+
print(
|
|
83
|
+
"[cyris] WARNING: no API key found. "
|
|
84
|
+
"Pass api_key=, set CYRIS_API_KEY, or run `cyris login`.",
|
|
85
|
+
file=sys.stderr,
|
|
86
|
+
)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
_config["api_key"] = resolved_key
|
|
91
|
+
_config["api_url"] = (api_url or _config["api_url"]).rstrip("/")
|
|
92
|
+
_config["agent_name"] = agent_name
|
|
93
|
+
_config["auto_patch"] = auto_patch
|
|
94
|
+
_config["initialised"] = True
|
|
95
|
+
|
|
96
|
+
# Configure and start the background buffer.
|
|
97
|
+
if resolved_key:
|
|
98
|
+
buffer.configure(_config["api_url"], resolved_key)
|
|
99
|
+
buffer.start()
|
|
100
|
+
|
|
101
|
+
# Monkey-patch provider libraries when requested.
|
|
102
|
+
if auto_patch:
|
|
103
|
+
try:
|
|
104
|
+
from . import patchers
|
|
105
|
+
patchers.apply_patches()
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
try:
|
|
108
|
+
print(f"[cyris] auto-patch failed: {exc}", file=sys.stderr)
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------ #
|
|
114
|
+
# log_call #
|
|
115
|
+
# ------------------------------------------------------------------ #
|
|
116
|
+
|
|
117
|
+
def log_call(
|
|
118
|
+
provider: str = "unknown",
|
|
119
|
+
model: str = "unknown",
|
|
120
|
+
input_text: str = "",
|
|
121
|
+
output_text: str = "",
|
|
122
|
+
input_tokens: int = 0,
|
|
123
|
+
output_tokens: int = 0,
|
|
124
|
+
latency_ms: float = 0.0,
|
|
125
|
+
status: str = "success",
|
|
126
|
+
error_message: Optional[str] = None,
|
|
127
|
+
session_id: Optional[str] = None,
|
|
128
|
+
agent_name: Optional[str] = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Record a single LLM invocation.
|
|
131
|
+
|
|
132
|
+
Safe to call even before ``init()`` -- events will be silently
|
|
133
|
+
dropped if no API key is configured.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
input_hash = hashlib.sha256(input_text.encode("utf-8")).hexdigest() if input_text else None
|
|
137
|
+
output_hash = hashlib.sha256(output_text.encode("utf-8")).hexdigest() if output_text else None
|
|
138
|
+
|
|
139
|
+
phi_result = detect_phi(input_text + " " + output_text)
|
|
140
|
+
fingerprints = generate_fingerprints(output_text)
|
|
141
|
+
|
|
142
|
+
event = {
|
|
143
|
+
"event_type": "llm_call",
|
|
144
|
+
"id": str(uuid.uuid4()),
|
|
145
|
+
"timestamp": time.time(),
|
|
146
|
+
"provider": provider,
|
|
147
|
+
"model": model,
|
|
148
|
+
"input_tokens": input_tokens,
|
|
149
|
+
"output_tokens": output_tokens,
|
|
150
|
+
"latency_ms": latency_ms,
|
|
151
|
+
"status": status,
|
|
152
|
+
"error_message": error_message,
|
|
153
|
+
"input_hash": input_hash,
|
|
154
|
+
"output_hash": output_hash,
|
|
155
|
+
"phi_detected": phi_result["detected"],
|
|
156
|
+
"phi_fields": phi_result["fields_found"],
|
|
157
|
+
"fingerprints": fingerprints,
|
|
158
|
+
"session_id": session_id or get_current_session(),
|
|
159
|
+
"agent_name": agent_name or _config.get("agent_name"),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
buffer.add_event(event)
|
|
163
|
+
except Exception: # noqa: BLE001
|
|
164
|
+
# NEVER crash the host application.
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------ #
|
|
169
|
+
# log_override #
|
|
170
|
+
# ------------------------------------------------------------------ #
|
|
171
|
+
|
|
172
|
+
def log_override(
|
|
173
|
+
invocation_id: str,
|
|
174
|
+
reason: str = "",
|
|
175
|
+
override_by: str = "",
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Record a human override of an agent's decision."""
|
|
178
|
+
try:
|
|
179
|
+
event = {
|
|
180
|
+
"event_type": "override",
|
|
181
|
+
"id": str(uuid.uuid4()),
|
|
182
|
+
"timestamp": time.time(),
|
|
183
|
+
"invocation_id": invocation_id,
|
|
184
|
+
"reason": reason,
|
|
185
|
+
"override_by": override_by,
|
|
186
|
+
"session_id": get_current_session(),
|
|
187
|
+
"agent_name": _config.get("agent_name"),
|
|
188
|
+
}
|
|
189
|
+
buffer.add_event(event)
|
|
190
|
+
except Exception: # noqa: BLE001
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------ #
|
|
195
|
+
# log_event #
|
|
196
|
+
# ------------------------------------------------------------------ #
|
|
197
|
+
|
|
198
|
+
def log_event(
|
|
199
|
+
event_type: str = "custom",
|
|
200
|
+
description: str = "",
|
|
201
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Record an arbitrary audit event."""
|
|
204
|
+
try:
|
|
205
|
+
event = {
|
|
206
|
+
"event_type": event_type,
|
|
207
|
+
"id": str(uuid.uuid4()),
|
|
208
|
+
"timestamp": time.time(),
|
|
209
|
+
"description": description,
|
|
210
|
+
"metadata": metadata or {},
|
|
211
|
+
"session_id": get_current_session(),
|
|
212
|
+
"agent_name": _config.get("agent_name"),
|
|
213
|
+
}
|
|
214
|
+
buffer.add_event(event)
|
|
215
|
+
except Exception: # noqa: BLE001
|
|
216
|
+
pass
|