aops 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.
- aops-0.1.0/LICENSE +21 -0
- aops-0.1.0/PKG-INFO +15 -0
- aops-0.1.0/README.md +160 -0
- aops-0.1.0/aops/__init__.py +32 -0
- aops-0.1.0/aops/_cache.py +33 -0
- aops-0.1.0/aops/_client.py +147 -0
- aops-0.1.0/aops/_config.py +90 -0
- aops-0.1.0/aops/_exceptions.py +18 -0
- aops-0.1.0/aops/_keys.py +83 -0
- aops-0.1.0/aops/_models.py +33 -0
- aops-0.1.0/aops/langchain/__init__.py +3 -0
- aops-0.1.0/aops/langchain/_loader.py +166 -0
- aops-0.1.0/aops.egg-info/PKG-INFO +15 -0
- aops-0.1.0/aops.egg-info/SOURCES.txt +17 -0
- aops-0.1.0/aops.egg-info/dependency_links.txt +1 -0
- aops-0.1.0/aops.egg-info/requires.txt +9 -0
- aops-0.1.0/aops.egg-info/top_level.txt +1 -0
- aops-0.1.0/pyproject.toml +26 -0
- aops-0.1.0/setup.cfg +4 -0
aops-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Park Kibum
|
|
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.
|
aops-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aops
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain integration library for AOps
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Requires-Dist: langchain-core>=0.3
|
|
10
|
+
Requires-Dist: python-dotenv>=1.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
14
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
aops-0.1.0/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# aops
|
|
2
|
+
|
|
3
|
+
LangChain integration library for [AOps](https://github.com/cow-coding/AgentOps) — load agent prompt configurations from the AOps backend and use them directly in LangChain chains.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python 3.12+
|
|
8
|
+
- AOps backend running (self-hosted)
|
|
9
|
+
- API key issued from the AOps UI (Agent detail page → New API Key)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install aops
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from dotenv import load_dotenv
|
|
21
|
+
load_dotenv()
|
|
22
|
+
|
|
23
|
+
import aops
|
|
24
|
+
aops.init(api_key="aops_...")
|
|
25
|
+
|
|
26
|
+
from aops.langchain import pull
|
|
27
|
+
from langchain_core.prompts import ChatPromptTemplate, HumanMessagePromptTemplate
|
|
28
|
+
from langchain_core.output_parsers import StrOutputParser
|
|
29
|
+
from langchain_openai import ChatOpenAI
|
|
30
|
+
|
|
31
|
+
system_prompt = pull("my-agent/my-chain")
|
|
32
|
+
|
|
33
|
+
chain = (
|
|
34
|
+
ChatPromptTemplate.from_messages([
|
|
35
|
+
system_prompt,
|
|
36
|
+
HumanMessagePromptTemplate.from_template("{user_input}"),
|
|
37
|
+
])
|
|
38
|
+
| ChatOpenAI(model="gpt-4o-mini")
|
|
39
|
+
| StrOutputParser()
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
result = chain.invoke({"user_input": "Hello!"})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
### API Key
|
|
48
|
+
|
|
49
|
+
AOps API keys embed the server host, so no separate `base_url` is needed.
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
aops_{base64(host)}_{token}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Issue a key from the AOps UI: **Agent detail page → API Keys → New API Key**
|
|
56
|
+
|
|
57
|
+
### `aops.init()`
|
|
58
|
+
|
|
59
|
+
Call `init()` once before using any aops functions:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import aops
|
|
63
|
+
|
|
64
|
+
aops.init(api_key="aops_...")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or use environment variables — `init()` is optional when env vars are set:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# .env
|
|
71
|
+
AGENTOPS_API_KEY=aops_...
|
|
72
|
+
OPENAI_API_KEY=sk-...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Environment Variable | Default | Description |
|
|
76
|
+
|------------------------|--------------------------|--------------------------------------|
|
|
77
|
+
| `AGENTOPS_API_KEY` | — | API key (host is parsed from it) |
|
|
78
|
+
| `AGENTOPS_BASE_URL` | parsed from key | Override the host embedded in the key |
|
|
79
|
+
| `AGENTOPS_API_PREFIX` | `/api/v1` | API path prefix |
|
|
80
|
+
| `AGENTOPS_CACHE_TTL` | `300` | Prompt cache TTL in seconds (`0` = no cache) |
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
### `pull(ref, *, version=None)`
|
|
85
|
+
|
|
86
|
+
Fetch a chain from AOps and return a `SystemMessagePromptTemplate`.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from aops.langchain import pull
|
|
90
|
+
|
|
91
|
+
prompt = pull("my-agent/my-chain") # latest
|
|
92
|
+
prompt = pull("my-agent/my-chain", version=2) # pinned version
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The chain's `persona` and `content` are merged into a single system message:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
# Persona
|
|
99
|
+
{persona}
|
|
100
|
+
|
|
101
|
+
# Content
|
|
102
|
+
{content}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`content` may contain LangChain template variables (e.g. `{language}`).
|
|
106
|
+
`persona` is treated as a fixed string — its braces are escaped automatically.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
### `@chain_prompt(agent_name, chain_name, *, version=None)`
|
|
111
|
+
|
|
112
|
+
Decorator that fetches the prompt and injects it as the first argument.
|
|
113
|
+
|
|
114
|
+
**Function decorator**
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from aops.langchain import chain_prompt
|
|
118
|
+
from langchain_core.prompts import SystemMessagePromptTemplate
|
|
119
|
+
|
|
120
|
+
@chain_prompt("my-agent", "my-chain")
|
|
121
|
+
def answer(prompt: SystemMessagePromptTemplate, user_input: str) -> str:
|
|
122
|
+
return (
|
|
123
|
+
ChatPromptTemplate.from_messages([
|
|
124
|
+
prompt,
|
|
125
|
+
HumanMessagePromptTemplate.from_template("{user_input}"),
|
|
126
|
+
])
|
|
127
|
+
| ChatOpenAI(model="gpt-4o-mini")
|
|
128
|
+
| StrOutputParser()
|
|
129
|
+
).invoke({"user_input": user_input})
|
|
130
|
+
|
|
131
|
+
result = answer(user_input="What is AOps?")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Class decorator**
|
|
135
|
+
|
|
136
|
+
The prompt is injected into `__init__` as the first argument after `self`. Build the chain once and reuse it:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
@chain_prompt("my-agent", "my-chain")
|
|
140
|
+
class MyAgent:
|
|
141
|
+
def __init__(self, prompt: SystemMessagePromptTemplate) -> None:
|
|
142
|
+
self.chain = (
|
|
143
|
+
ChatPromptTemplate.from_messages([
|
|
144
|
+
prompt,
|
|
145
|
+
HumanMessagePromptTemplate.from_template("{user_input}"),
|
|
146
|
+
])
|
|
147
|
+
| ChatOpenAI(model="gpt-4o-mini")
|
|
148
|
+
| StrOutputParser()
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def run(self, user_input: str) -> str:
|
|
152
|
+
return self.chain.invoke({"user_input": user_input})
|
|
153
|
+
|
|
154
|
+
agent = MyAgent()
|
|
155
|
+
result = agent.run(user_input="Hello!")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""aops — LangChain integration for AgentOps.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import aops
|
|
6
|
+
aops.init(api_key="aops_aHR0cDovL2xvY2FsaG9zdDo4MDAw_...")
|
|
7
|
+
|
|
8
|
+
from aops.langchain import pull, chain_prompt
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from aops._client import AopsClient
|
|
12
|
+
from aops._config import init
|
|
13
|
+
from aops._exceptions import (
|
|
14
|
+
AgentNotFoundError,
|
|
15
|
+
AopsConnectionError,
|
|
16
|
+
AopsError,
|
|
17
|
+
ChainNotFoundError,
|
|
18
|
+
VersionNotFoundError,
|
|
19
|
+
)
|
|
20
|
+
from aops._keys import generate_key, parse_key
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"init",
|
|
24
|
+
"AopsClient",
|
|
25
|
+
"generate_key",
|
|
26
|
+
"parse_key",
|
|
27
|
+
"AopsError",
|
|
28
|
+
"AgentNotFoundError",
|
|
29
|
+
"ChainNotFoundError",
|
|
30
|
+
"VersionNotFoundError",
|
|
31
|
+
"AopsConnectionError",
|
|
32
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TTLCache:
|
|
6
|
+
"""Simple in-memory cache with TTL expiration."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, ttl: int = 300) -> None:
|
|
9
|
+
self._ttl = ttl
|
|
10
|
+
self._store: dict[str, tuple[Any, float]] = {}
|
|
11
|
+
|
|
12
|
+
def get(self, key: str) -> Any | None:
|
|
13
|
+
if self._ttl == 0:
|
|
14
|
+
return None
|
|
15
|
+
entry = self._store.get(key)
|
|
16
|
+
if entry is None:
|
|
17
|
+
return None
|
|
18
|
+
value, ts = entry
|
|
19
|
+
if time.monotonic() - ts > self._ttl:
|
|
20
|
+
del self._store[key]
|
|
21
|
+
return None
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
def set(self, key: str, value: Any) -> None:
|
|
25
|
+
if self._ttl == 0:
|
|
26
|
+
return
|
|
27
|
+
self._store[key] = (value, time.monotonic())
|
|
28
|
+
|
|
29
|
+
def invalidate(self, key: str) -> None:
|
|
30
|
+
self._store.pop(key, None)
|
|
31
|
+
|
|
32
|
+
def clear(self) -> None:
|
|
33
|
+
self._store.clear()
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from aops._cache import TTLCache
|
|
6
|
+
from aops._config import get_config
|
|
7
|
+
from aops._exceptions import (
|
|
8
|
+
AgentNotFoundError,
|
|
9
|
+
AopsConnectionError,
|
|
10
|
+
ChainNotFoundError,
|
|
11
|
+
VersionNotFoundError,
|
|
12
|
+
)
|
|
13
|
+
from aops._keys import InvalidApiKeyError, parse_key
|
|
14
|
+
from aops._models import AgentModel, ChainModel, ChainVersionModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AopsClient:
|
|
18
|
+
"""HTTP client for the AgentOps backend API."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
api_key: str | None = None,
|
|
23
|
+
*,
|
|
24
|
+
base_url: str | None = None,
|
|
25
|
+
api_prefix: str | None = None,
|
|
26
|
+
cache_ttl: int | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
if api_key is not None or base_url is not None:
|
|
29
|
+
# Explicit construction — resolve host from key, then allow override
|
|
30
|
+
resolved_url = base_url or _host_from_key(api_key)
|
|
31
|
+
self._api_base = f"{resolved_url.rstrip('/')}{api_prefix or '/api/v1'}"
|
|
32
|
+
self._api_key = api_key
|
|
33
|
+
ttl = cache_ttl if cache_ttl is not None else 300
|
|
34
|
+
else:
|
|
35
|
+
config = get_config()
|
|
36
|
+
self._api_base = config.api_base
|
|
37
|
+
self._api_key = config.api_key
|
|
38
|
+
ttl = cache_ttl if cache_ttl is not None else config.cache_ttl
|
|
39
|
+
|
|
40
|
+
self._cache = TTLCache(ttl)
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
# Internal helpers
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def _headers(self) -> dict[str, str]:
|
|
47
|
+
if self._api_key:
|
|
48
|
+
return {"Authorization": f"Bearer {self._api_key}"}
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
def _get(self, path: str) -> list | dict:
|
|
52
|
+
url = f"{self._api_base}{path}"
|
|
53
|
+
try:
|
|
54
|
+
with httpx.Client() as client:
|
|
55
|
+
response = client.get(url, headers=self._headers())
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
return response.json()
|
|
58
|
+
except httpx.ConnectError as exc:
|
|
59
|
+
raise AopsConnectionError(
|
|
60
|
+
f"Cannot reach AgentOps at '{self._api_base}'. "
|
|
61
|
+
"Check that the backend is running and your API key is correct."
|
|
62
|
+
) from exc
|
|
63
|
+
except httpx.HTTPStatusError as exc:
|
|
64
|
+
status = exc.response.status_code
|
|
65
|
+
if status == 401:
|
|
66
|
+
raise AopsConnectionError(
|
|
67
|
+
"AgentOps rejected the API key (401 Unauthorized). "
|
|
68
|
+
"Check that AGENTOPS_API_KEY is valid."
|
|
69
|
+
) from exc
|
|
70
|
+
if status == 403:
|
|
71
|
+
raise AopsConnectionError(
|
|
72
|
+
"Access denied (403 Forbidden). "
|
|
73
|
+
"This API key may not have access to the requested agent."
|
|
74
|
+
) from exc
|
|
75
|
+
raise AopsConnectionError(
|
|
76
|
+
f"AgentOps returned {status} for {url}"
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# Public API
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def get_agent_by_name(self, agent_name: str) -> AgentModel:
|
|
84
|
+
cache_key = f"agent:{agent_name}"
|
|
85
|
+
cached = self._cache.get(cache_key)
|
|
86
|
+
if cached is not None:
|
|
87
|
+
return cached
|
|
88
|
+
|
|
89
|
+
agents = [AgentModel(**a) for a in self._get("/agents/")]
|
|
90
|
+
for agent in agents:
|
|
91
|
+
if agent.name == agent_name:
|
|
92
|
+
self._cache.set(cache_key, agent)
|
|
93
|
+
return agent
|
|
94
|
+
|
|
95
|
+
raise AgentNotFoundError(
|
|
96
|
+
f"Agent '{agent_name}' not found. "
|
|
97
|
+
f"Available agents: {[a.name for a in agents] or '(none)'}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def get_chain_by_name(self, agent_id: uuid.UUID, chain_name: str) -> ChainModel:
|
|
101
|
+
cache_key = f"chain:{agent_id}:{chain_name}"
|
|
102
|
+
cached = self._cache.get(cache_key)
|
|
103
|
+
if cached is not None:
|
|
104
|
+
return cached
|
|
105
|
+
|
|
106
|
+
chains = [ChainModel(**c) for c in self._get(f"/agents/{agent_id}/chains/")]
|
|
107
|
+
for chain in chains:
|
|
108
|
+
if chain.name == chain_name:
|
|
109
|
+
self._cache.set(cache_key, chain)
|
|
110
|
+
return chain
|
|
111
|
+
|
|
112
|
+
raise ChainNotFoundError(
|
|
113
|
+
f"Chain '{chain_name}' not found. "
|
|
114
|
+
f"Available chains: {[c.name for c in chains] or '(none)'}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def get_chain_version(
|
|
118
|
+
self, agent_id: uuid.UUID, chain_id: uuid.UUID, version_number: int
|
|
119
|
+
) -> ChainVersionModel:
|
|
120
|
+
cache_key = f"version:{chain_id}:{version_number}"
|
|
121
|
+
cached = self._cache.get(cache_key)
|
|
122
|
+
if cached is not None:
|
|
123
|
+
return cached
|
|
124
|
+
|
|
125
|
+
versions = [
|
|
126
|
+
ChainVersionModel(**v)
|
|
127
|
+
for v in self._get(f"/agents/{agent_id}/chains/{chain_id}/versions/")
|
|
128
|
+
]
|
|
129
|
+
for version in versions:
|
|
130
|
+
if version.version_number == version_number:
|
|
131
|
+
self._cache.set(cache_key, version)
|
|
132
|
+
return version
|
|
133
|
+
|
|
134
|
+
available = sorted(v.version_number for v in versions)
|
|
135
|
+
raise VersionNotFoundError(
|
|
136
|
+
f"Version {version_number} not found. Available versions: {available or '(none)'}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _host_from_key(api_key: str | None) -> str:
|
|
141
|
+
if api_key is None:
|
|
142
|
+
return "http://localhost:8000"
|
|
143
|
+
try:
|
|
144
|
+
host, _ = parse_key(api_key)
|
|
145
|
+
return host
|
|
146
|
+
except InvalidApiKeyError:
|
|
147
|
+
return "http://localhost:8000"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from aops._keys import InvalidApiKeyError, parse_key
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Config:
|
|
9
|
+
base_url: str = "http://localhost:8000"
|
|
10
|
+
api_prefix: str = "/api/v1"
|
|
11
|
+
api_key: str | None = None
|
|
12
|
+
cache_ttl: int = 300 # seconds; 0 = no cache
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def api_base(self) -> str:
|
|
16
|
+
return f"{self.base_url.rstrip('/')}{self.api_prefix}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_config: Config | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def init(
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
*,
|
|
25
|
+
base_url: str | None = None,
|
|
26
|
+
api_prefix: str | None = None,
|
|
27
|
+
cache_ttl: int | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Configure the AgentOps connection.
|
|
30
|
+
|
|
31
|
+
Preferred usage — key only (host is parsed from the key automatically)::
|
|
32
|
+
|
|
33
|
+
aops.init(api_key="aops_aHR0cDovL2xvY2FsaG9zdDo4MDAw_...")
|
|
34
|
+
|
|
35
|
+
Manual URL override (e.g. for testing or reverse-proxy setups)::
|
|
36
|
+
|
|
37
|
+
aops.init(api_key="aops_...", base_url="http://my-proxy:9000")
|
|
38
|
+
|
|
39
|
+
Environment variables (when ``init()`` is not called explicitly):
|
|
40
|
+
AGENTOPS_API_KEY — the API key (host is parsed from it)
|
|
41
|
+
AGENTOPS_BASE_URL — overrides the host embedded in the key
|
|
42
|
+
AGENTOPS_API_PREFIX — default: /api/v1
|
|
43
|
+
AGENTOPS_CACHE_TTL — default: 300 (seconds)
|
|
44
|
+
"""
|
|
45
|
+
global _config
|
|
46
|
+
|
|
47
|
+
resolved_key = api_key or os.getenv("AGENTOPS_API_KEY")
|
|
48
|
+
explicit_url = base_url or os.getenv("AGENTOPS_BASE_URL")
|
|
49
|
+
|
|
50
|
+
resolved_url = _resolve_base_url(resolved_key, explicit_url)
|
|
51
|
+
|
|
52
|
+
_config = Config(
|
|
53
|
+
base_url=resolved_url,
|
|
54
|
+
api_prefix=api_prefix or os.getenv("AGENTOPS_API_PREFIX", "/api/v1"),
|
|
55
|
+
api_key=resolved_key,
|
|
56
|
+
cache_ttl=cache_ttl if cache_ttl is not None else int(os.getenv("AGENTOPS_CACHE_TTL", "300")),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_config() -> Config:
|
|
61
|
+
global _config
|
|
62
|
+
if _config is None:
|
|
63
|
+
resolved_key = os.getenv("AGENTOPS_API_KEY")
|
|
64
|
+
explicit_url = os.getenv("AGENTOPS_BASE_URL")
|
|
65
|
+
_config = Config(
|
|
66
|
+
base_url=_resolve_base_url(resolved_key, explicit_url),
|
|
67
|
+
api_prefix=os.getenv("AGENTOPS_API_PREFIX", "/api/v1"),
|
|
68
|
+
api_key=resolved_key,
|
|
69
|
+
cache_ttl=int(os.getenv("AGENTOPS_CACHE_TTL", "300")),
|
|
70
|
+
)
|
|
71
|
+
return _config
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Internal helpers
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _resolve_base_url(api_key: str | None, explicit_url: str | None) -> str:
|
|
79
|
+
"""Determine the base URL from the key and/or an explicit override."""
|
|
80
|
+
if explicit_url:
|
|
81
|
+
return explicit_url
|
|
82
|
+
|
|
83
|
+
if api_key:
|
|
84
|
+
try:
|
|
85
|
+
host, _ = parse_key(api_key)
|
|
86
|
+
return host
|
|
87
|
+
except InvalidApiKeyError:
|
|
88
|
+
pass # fall through to default
|
|
89
|
+
|
|
90
|
+
return "http://localhost:8000"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class AopsError(Exception):
|
|
2
|
+
"""Base exception for aops."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AgentNotFoundError(AopsError):
|
|
6
|
+
"""Agent with the given name does not exist."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ChainNotFoundError(AopsError):
|
|
10
|
+
"""Chain with the given name does not exist."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VersionNotFoundError(AopsError):
|
|
14
|
+
"""The requested chain version does not exist."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AopsConnectionError(AopsError):
|
|
18
|
+
"""Failed to connect to the AgentOps backend."""
|
aops-0.1.0/aops/_keys.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""API key utilities for aops.
|
|
2
|
+
|
|
3
|
+
Key format:
|
|
4
|
+
aops_{base64url(host)}_{secret_token}
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
host = "http://localhost:8000"
|
|
8
|
+
key = "aops_aHR0cDovL2xvY2FsaG9zdDo4MDAw_xK9mP2abcXYZ..."
|
|
9
|
+
|
|
10
|
+
The host is base64url-encoded (no padding) inside the key, so the library
|
|
11
|
+
can resolve the AgentOps server without a separate base_url setting.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import secrets
|
|
16
|
+
|
|
17
|
+
_PREFIX = "aops"
|
|
18
|
+
_SEP = "_"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidApiKeyError(ValueError):
|
|
22
|
+
"""Raised when an API key cannot be parsed."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate_key(host: str) -> str:
|
|
26
|
+
"""Generate a new API key that embeds *host*.
|
|
27
|
+
|
|
28
|
+
This helper is intended for the AgentOps backend when issuing keys.
|
|
29
|
+
The returned key must be stored (hashed) and should be shown to the
|
|
30
|
+
user only once.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
host: Full base URL of the AgentOps server,
|
|
34
|
+
e.g. ``"http://localhost:8000"``.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A key string of the form ``"aops_{encoded_host}_{token}"``.
|
|
38
|
+
"""
|
|
39
|
+
encoded_host = _encode_host(host)
|
|
40
|
+
token = secrets.token_urlsafe(32)
|
|
41
|
+
return f"{_PREFIX}{_SEP}{encoded_host}{_SEP}{token}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_key(api_key: str) -> tuple[str, str]:
|
|
45
|
+
"""Parse an API key and return ``(host, token)``.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
api_key: A key produced by :func:`generate_key`.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
``(host, token)`` tuple where *host* is the raw base URL string.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
:class:`InvalidApiKeyError` if the key format is invalid.
|
|
55
|
+
"""
|
|
56
|
+
parts = api_key.split(_SEP, 2)
|
|
57
|
+
if len(parts) != 3 or parts[0] != _PREFIX:
|
|
58
|
+
raise InvalidApiKeyError(
|
|
59
|
+
f"Invalid API key format. Expected 'aops_<host>_<token>', got: '{api_key[:12]}...'"
|
|
60
|
+
)
|
|
61
|
+
_, encoded_host, token = parts
|
|
62
|
+
try:
|
|
63
|
+
host = _decode_host(encoded_host)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
raise InvalidApiKeyError(
|
|
66
|
+
f"Cannot decode host from API key: {exc}"
|
|
67
|
+
) from exc
|
|
68
|
+
return host, token
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Internal helpers
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def _encode_host(host: str) -> str:
|
|
76
|
+
"""base64url-encode *host* without padding."""
|
|
77
|
+
return base64.urlsafe_b64encode(host.encode()).decode().rstrip("=")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _decode_host(encoded: str) -> str:
|
|
81
|
+
"""Decode a base64url *encoded* host (handles missing padding)."""
|
|
82
|
+
padded = encoded + "=" * (-len(encoded) % 4)
|
|
83
|
+
return base64.urlsafe_b64decode(padded).decode()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AgentModel(BaseModel):
|
|
8
|
+
id: uuid.UUID
|
|
9
|
+
name: str
|
|
10
|
+
description: str | None
|
|
11
|
+
created_at: datetime
|
|
12
|
+
updated_at: datetime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChainModel(BaseModel):
|
|
16
|
+
id: uuid.UUID
|
|
17
|
+
agent_id: uuid.UUID
|
|
18
|
+
name: str
|
|
19
|
+
description: str | None
|
|
20
|
+
persona: str
|
|
21
|
+
content: str
|
|
22
|
+
created_at: datetime
|
|
23
|
+
updated_at: datetime
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ChainVersionModel(BaseModel):
|
|
27
|
+
id: uuid.UUID
|
|
28
|
+
chain_id: uuid.UUID
|
|
29
|
+
persona: str
|
|
30
|
+
content: str
|
|
31
|
+
message: str
|
|
32
|
+
version_number: int
|
|
33
|
+
created_at: datetime
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from langchain_core.prompts import SystemMessagePromptTemplate
|
|
6
|
+
|
|
7
|
+
from aops._client import AopsClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _to_system_prompt(persona: str, content: str) -> SystemMessagePromptTemplate:
|
|
11
|
+
"""Convert AgentOps chain fields into a SystemMessagePromptTemplate.
|
|
12
|
+
|
|
13
|
+
Both ``persona`` and ``content`` are agent-authored system-level instructions,
|
|
14
|
+
merged into a single SystemMessage:
|
|
15
|
+
|
|
16
|
+
# Persona
|
|
17
|
+
<persona text>
|
|
18
|
+
|
|
19
|
+
# Content
|
|
20
|
+
<content text — may contain {template_variables}>
|
|
21
|
+
|
|
22
|
+
``persona`` is treated as a fixed string (its braces are escaped).
|
|
23
|
+
``content`` may contain LangChain template variables in single braces,
|
|
24
|
+
e.g. ``"Answer in {language}"``.
|
|
25
|
+
To include a literal brace in content use double braces: ``{{`` or ``}}``.
|
|
26
|
+
"""
|
|
27
|
+
persona_escaped = persona.replace("{", "{{").replace("}", "}}")
|
|
28
|
+
system_text = f"# Persona\n{persona_escaped}\n\n# Content\n{content}"
|
|
29
|
+
return SystemMessagePromptTemplate.from_template(system_text)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pull(
|
|
33
|
+
ref: str,
|
|
34
|
+
*,
|
|
35
|
+
version: int | None = None,
|
|
36
|
+
client: AopsClient | None = None,
|
|
37
|
+
) -> SystemMessagePromptTemplate:
|
|
38
|
+
"""Fetch a chain from AgentOps and return it as a SystemMessagePromptTemplate.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
ref: ``"agent-name/chain-name"`` reference string.
|
|
42
|
+
version: Specific version number to load. ``None`` loads the current
|
|
43
|
+
(latest saved) chain content.
|
|
44
|
+
client: Optional pre-configured :class:`~aops._client.AopsClient`.
|
|
45
|
+
When omitted the global configuration is used.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A :class:`langchain_core.prompts.SystemMessagePromptTemplate` combining
|
|
49
|
+
the chain's ``persona`` and ``content`` into a single system message.
|
|
50
|
+
|
|
51
|
+
Example::
|
|
52
|
+
|
|
53
|
+
from aops.langchain import pull
|
|
54
|
+
|
|
55
|
+
prompt = pull("customer-support/greeter")
|
|
56
|
+
prompt_v2 = pull("customer-support/greeter", version=2)
|
|
57
|
+
|
|
58
|
+
chain = prompt | ChatOpenAI() | StrOutputParser()
|
|
59
|
+
chain.invoke({"topic": "billing"})
|
|
60
|
+
"""
|
|
61
|
+
if "/" not in ref:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Invalid ref '{ref}'. Expected format: 'agent-name/chain-name'."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
agent_name, chain_name = ref.split("/", 1)
|
|
67
|
+
c = client or AopsClient()
|
|
68
|
+
|
|
69
|
+
agent = c.get_agent_by_name(agent_name)
|
|
70
|
+
chain = c.get_chain_by_name(agent.id, chain_name)
|
|
71
|
+
|
|
72
|
+
if version is not None:
|
|
73
|
+
v = c.get_chain_version(agent.id, chain.id, version)
|
|
74
|
+
return _to_system_prompt(v.persona, v.content)
|
|
75
|
+
|
|
76
|
+
return _to_system_prompt(chain.persona, chain.content)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def chain_prompt(
|
|
80
|
+
agent_name: str,
|
|
81
|
+
chain_name: str,
|
|
82
|
+
*,
|
|
83
|
+
version: int | None = None,
|
|
84
|
+
client: AopsClient | None = None,
|
|
85
|
+
):
|
|
86
|
+
"""Decorator that injects a ``SystemMessagePromptTemplate`` from AgentOps.
|
|
87
|
+
|
|
88
|
+
The prompt is fetched once on the first call and then served from the
|
|
89
|
+
client-level cache on subsequent calls.
|
|
90
|
+
|
|
91
|
+
**Function decorator** — the resolved prompt is passed as the first
|
|
92
|
+
positional argument::
|
|
93
|
+
|
|
94
|
+
@chain_prompt("my-agent", "summariser")
|
|
95
|
+
def summarise(prompt: SystemMessagePromptTemplate, text: str) -> str:
|
|
96
|
+
return (prompt | ChatOpenAI()).invoke({"text": text})
|
|
97
|
+
|
|
98
|
+
result = summarise(text="Long article...")
|
|
99
|
+
|
|
100
|
+
**Class decorator** — the resolved prompt is passed as the second
|
|
101
|
+
positional argument to ``__init__`` (after ``self``)::
|
|
102
|
+
|
|
103
|
+
@chain_prompt("my-agent", "summariser")
|
|
104
|
+
class Summariser:
|
|
105
|
+
def __init__(self, prompt: SystemMessagePromptTemplate) -> None:
|
|
106
|
+
self.chain = prompt | ChatOpenAI()
|
|
107
|
+
|
|
108
|
+
def run(self, text: str) -> str:
|
|
109
|
+
return self.chain.invoke({"text": text})
|
|
110
|
+
|
|
111
|
+
summariser = Summariser()
|
|
112
|
+
result = summariser.run(text="Long article...")
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
agent_name: Name of the agent registered in AgentOps.
|
|
116
|
+
chain_name: Name of the chain within that agent.
|
|
117
|
+
version: Version number to pin. ``None`` = latest.
|
|
118
|
+
client: Optional custom :class:`~aops._client.AopsClient`.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def decorator(target: type | Callable) -> type | Callable:
|
|
122
|
+
if inspect.isclass(target):
|
|
123
|
+
return _wrap_class(target, agent_name, chain_name, version, client)
|
|
124
|
+
return _wrap_function(target, agent_name, chain_name, version, client)
|
|
125
|
+
|
|
126
|
+
return decorator
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Internal wrappers
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _wrap_function(
|
|
134
|
+
func: Callable,
|
|
135
|
+
agent_name: str,
|
|
136
|
+
chain_name: str,
|
|
137
|
+
version: int | None,
|
|
138
|
+
client: AopsClient | None,
|
|
139
|
+
) -> Callable:
|
|
140
|
+
ref = f"{agent_name}/{chain_name}"
|
|
141
|
+
|
|
142
|
+
@functools.wraps(func)
|
|
143
|
+
def wrapper(*args, **kwargs):
|
|
144
|
+
prompt = pull(ref, version=version, client=client)
|
|
145
|
+
return func(prompt, *args, **kwargs)
|
|
146
|
+
|
|
147
|
+
return wrapper
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _wrap_class(
|
|
151
|
+
cls: type,
|
|
152
|
+
agent_name: str,
|
|
153
|
+
chain_name: str,
|
|
154
|
+
version: int | None,
|
|
155
|
+
client: AopsClient | None,
|
|
156
|
+
) -> type:
|
|
157
|
+
ref = f"{agent_name}/{chain_name}"
|
|
158
|
+
original_init = cls.__init__
|
|
159
|
+
|
|
160
|
+
@functools.wraps(original_init)
|
|
161
|
+
def new_init(self, *args, **kwargs):
|
|
162
|
+
prompt = pull(ref, version=version, client=client)
|
|
163
|
+
original_init(self, prompt, *args, **kwargs)
|
|
164
|
+
|
|
165
|
+
cls.__init__ = new_init
|
|
166
|
+
return cls
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aops
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain integration library for AOps
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Requires-Dist: langchain-core>=0.3
|
|
10
|
+
Requires-Dist: python-dotenv>=1.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
14
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
aops/__init__.py
|
|
5
|
+
aops/_cache.py
|
|
6
|
+
aops/_client.py
|
|
7
|
+
aops/_config.py
|
|
8
|
+
aops/_exceptions.py
|
|
9
|
+
aops/_keys.py
|
|
10
|
+
aops/_models.py
|
|
11
|
+
aops.egg-info/PKG-INFO
|
|
12
|
+
aops.egg-info/SOURCES.txt
|
|
13
|
+
aops.egg-info/dependency_links.txt
|
|
14
|
+
aops.egg-info/requires.txt
|
|
15
|
+
aops.egg-info/top_level.txt
|
|
16
|
+
aops/langchain/__init__.py
|
|
17
|
+
aops/langchain/_loader.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aops
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aops"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain integration library for AOps"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.27",
|
|
12
|
+
"pydantic>=2.0",
|
|
13
|
+
"langchain-core>=0.3",
|
|
14
|
+
"python-dotenv>=1.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0",
|
|
20
|
+
"pytest-asyncio>=0.23",
|
|
21
|
+
"respx>=0.21",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["."]
|
|
26
|
+
include = ["aops*"]
|
aops-0.1.0/setup.cfg
ADDED