acruxcore 0.4.1__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.
- acruxcore-0.4.1/.gitignore +8 -0
- acruxcore-0.4.1/PKG-INFO +246 -0
- acruxcore-0.4.1/README.md +219 -0
- acruxcore-0.4.1/pyproject.toml +52 -0
- acruxcore-0.4.1/src/acruxcore/__init__.py +81 -0
- acruxcore-0.4.1/src/acruxcore/cache.py +66 -0
- acruxcore-0.4.1/src/acruxcore/client.py +886 -0
- acruxcore-0.4.1/src/acruxcore/errors.py +48 -0
- acruxcore-0.4.1/src/acruxcore/http.py +76 -0
- acruxcore-0.4.1/src/acruxcore/py.typed +0 -0
- acruxcore-0.4.1/src/acruxcore/types.py +368 -0
- acruxcore-0.4.1/tests/e2e_live.py +189 -0
- acruxcore-0.4.1/tests/test_unit.py +549 -0
acruxcore-0.4.1/PKG-INFO
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: acruxcore
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: Async Python SDK for Acrux Core — runtime prompt render, gateway chat, tool loops, traces, and feedback
|
|
5
|
+
Project-URL: Homepage, https://github.com/talhaanwarch/agenticprompthub/tree/main/packages/sdk-python#readme
|
|
6
|
+
Project-URL: Repository, https://github.com/talhaanwarch/agenticprompthub
|
|
7
|
+
Project-URL: Issues, https://github.com/talhaanwarch/agenticprompthub/issues
|
|
8
|
+
Author: Acrux Core
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: acruxcore,ai-gateway,llm,observability,prompt-management,sdk,tracing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: httpx<1,>=0.27
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# acruxcore (Python)
|
|
29
|
+
|
|
30
|
+
Async Python SDK for Acrux Core. Fetch rendered prompts at runtime, call the AI
|
|
31
|
+
gateway, run client-side tool loops, and report/read traces — full feature
|
|
32
|
+
parity with the [TypeScript SDK](https://www.npmjs.com/package/@acruxcoreai/sdk),
|
|
33
|
+
with a Pythonic `async`/`await` API.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install acruxcore
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Python 3.9+. Depends only on [`httpx`](https://www.python-httpx.org/).
|
|
42
|
+
|
|
43
|
+
> **Using Node or TypeScript instead?** Install the JavaScript SDK from npm:
|
|
44
|
+
> `npm install @acruxcoreai/sdk` — see
|
|
45
|
+
> [`@acruxcoreai/sdk` on npm](https://www.npmjs.com/package/@acruxcoreai/sdk).
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import asyncio
|
|
51
|
+
from acruxcore import AcruxCore
|
|
52
|
+
|
|
53
|
+
async def main():
|
|
54
|
+
async with AcruxCore(
|
|
55
|
+
api_key="...", # or env ACRUXCORE_API_KEY
|
|
56
|
+
base_url="https://api.acruxcore.com/api/v1", # or env ACRUXCORE_BASE_URL
|
|
57
|
+
) as hub:
|
|
58
|
+
result = await hub.render_prompt("summarise-article", "production", {"article": "..."})
|
|
59
|
+
print(result.messages)
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`AcruxCore` owns an `httpx.AsyncClient`, so use it as an async context manager
|
|
65
|
+
(`async with`) or call `await hub.aclose()` when done. Create one instance at
|
|
66
|
+
startup and reuse it — the render cache is a process-wide singleton.
|
|
67
|
+
|
|
68
|
+
## Chat
|
|
69
|
+
|
|
70
|
+
`chat()` is a single, non-looping call to the gateway's OpenAI-compatible
|
|
71
|
+
`POST /gateway/chat/completions`. It routes to the right provider, prices the
|
|
72
|
+
call, and records a trace server-side.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
r = await hub.chat("gpt-4o-mini", [{"role": "user", "content": "Say hi in one word."}])
|
|
76
|
+
print(r.content) # 'Hello!'
|
|
77
|
+
print(r.finish_reason) # 'stop'
|
|
78
|
+
print(r.usage) # ChatUsage(prompt_tokens=..., completion_tokens=..., total_tokens=...)
|
|
79
|
+
print(r.gateway) # GatewayCallMeta(request_id=..., provider=..., cost_usd=..., cache=...)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Pass `tools=` / `tool_refs=` / `tool_choice=` just like the raw endpoint. If the
|
|
83
|
+
model calls a tool, `chat()` hands it back raw on `r.message["tool_calls"]` — it
|
|
84
|
+
never dispatches. Use `run_tool_loop()` for that.
|
|
85
|
+
|
|
86
|
+
## Streaming
|
|
87
|
+
|
|
88
|
+
Pass `stream=True` to get an async iterator of chunks:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
async for chunk in await hub.chat("gpt-4o-mini", messages, stream=True):
|
|
92
|
+
print(chunk.delta.get("content", ""), end="", flush=True)
|
|
93
|
+
if chunk.finish_reason:
|
|
94
|
+
print(f"\n(done: {chunk.finish_reason})")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each `chunk` mirrors one `chat.completion.chunk` SSE frame (`id`, `model`,
|
|
98
|
+
`delta`, `finish_reason`); iteration ends when the gateway sends `data: [DONE]`.
|
|
99
|
+
|
|
100
|
+
## Tools
|
|
101
|
+
|
|
102
|
+
`render_prompt()` returns `RenderResult(messages, tools)` — `tools` are any Tool
|
|
103
|
+
Catalog tools attached to that prompt version, already in OpenAI shape. Feed
|
|
104
|
+
them into `run_tool_loop()` to drive the whole tool-calling round-trip:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
async def dispatch(name: str, args: dict):
|
|
108
|
+
if name == "get_weather":
|
|
109
|
+
return {"tempC": 18, "condition": "cloudy"}
|
|
110
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
111
|
+
|
|
112
|
+
result = await hub.run_tool_loop(
|
|
113
|
+
model="gpt-4o",
|
|
114
|
+
messages=render.messages,
|
|
115
|
+
dispatch=dispatch,
|
|
116
|
+
tools=render.tools,
|
|
117
|
+
)
|
|
118
|
+
print(result.content) # final assistant text
|
|
119
|
+
print(result.messages) # full transcript, incl. tool calls/results
|
|
120
|
+
print(result.iterations) # number of model round-trips
|
|
121
|
+
print(result.trace_id) # trace covering every round-trip + tool dispatch
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`run_tool_loop()` stops when the model responds without calling a tool, or after
|
|
125
|
+
`max_iterations` round-trips (default 10; `result.stopped_at_limit` is `True`
|
|
126
|
+
then). When the model requests several tools in one turn they are dispatched
|
|
127
|
+
**concurrently** (`asyncio.gather`); results are appended in call order, so
|
|
128
|
+
`dispatch` must be safe to run in parallel. A `dispatch` that raises is not
|
|
129
|
+
caught — wrap it yourself if you want a tool failure reported back to the model
|
|
130
|
+
as a tool-result message instead of aborting the loop.
|
|
131
|
+
|
|
132
|
+
The loop auto-reports one trace: the gateway records an `llm` span per
|
|
133
|
+
round-trip, and the SDK adds a `tool` span per `dispatch` call, threaded into the
|
|
134
|
+
same trace via the `x-trace-id` header. Turn it off with `trace=False`, or attach
|
|
135
|
+
to an existing trace with `trace={"trace_id": "..."}`.
|
|
136
|
+
|
|
137
|
+
## Reporting traces
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from datetime import datetime, timezone
|
|
141
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
142
|
+
|
|
143
|
+
res = await hub.trace({
|
|
144
|
+
"name": "support-agent-run",
|
|
145
|
+
"spans": [
|
|
146
|
+
{"spanId": "s1", "name": "gpt-4o-mini", "kind": "llm", "startTime": now, "endTime": now,
|
|
147
|
+
"model": "gpt-4o-mini", "usage": {"promptTokens": 120, "completionTokens": 40, "totalTokens": 160}},
|
|
148
|
+
{"spanId": "s2", "parentSpanId": "s1", "name": "search_docs", "kind": "tool",
|
|
149
|
+
"startTime": now, "attributes": {"query": "refunds"}},
|
|
150
|
+
],
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
# Append another span to the same trace later:
|
|
154
|
+
await hub.trace({"traceId": res.trace_id, "spans": [
|
|
155
|
+
{"spanId": "s3", "parentSpanId": "s1", "name": "finalize", "kind": "chain", "startTime": now}]})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`kind` is one of `llm | tool | retrieval | embedding | agent | chain | other`;
|
|
159
|
+
`status` is `ok | error | unset`. `input`/`output` are stored only when your team
|
|
160
|
+
has payload capture on (or you pass `capturePayloads: True`). Up to 200 spans per
|
|
161
|
+
call. Span keys are camelCase (`spanId`, `parentSpanId`, `startTime`) because they
|
|
162
|
+
are sent to the API verbatim.
|
|
163
|
+
|
|
164
|
+
## Feedback
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
fb = await hub.submit_feedback(
|
|
168
|
+
trace_id,
|
|
169
|
+
rating=-1, # -1..5
|
|
170
|
+
label="wrong_answer",
|
|
171
|
+
comment="The tool call missed relevant docs.",
|
|
172
|
+
source="end_user", # 'user' | 'developer' | 'end_user' | 'api'
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
await hub.submit_feedback(trace_id, span_id="s1", rating=5) # scope to one span
|
|
176
|
+
|
|
177
|
+
# Edit later (author only). Pass a value to change, None to clear, omit to keep:
|
|
178
|
+
await hub.update_feedback(trace_id, fb.id, rating=1)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
At least one of `rating` / `label` / `comment` is required per call.
|
|
182
|
+
|
|
183
|
+
## Reading traces back
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
detail = await hub.get_trace(trace_id)
|
|
187
|
+
print(detail.trace.status, detail.trace.total_cost_usd, detail.trace.total_tokens)
|
|
188
|
+
print(detail.spans[0].model, detail.spans[0].latency_ms)
|
|
189
|
+
|
|
190
|
+
page = await hub.list_traces(session_id="tokyo-trip-plan-01", limit=10)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Configuration
|
|
194
|
+
|
|
195
|
+
| Argument | Environment Variable | Default | Description |
|
|
196
|
+
|----------|---------------------|---------|-------------|
|
|
197
|
+
| `api_key` | `ACRUXCORE_API_KEY` | **required** | Your Acrux Core API key |
|
|
198
|
+
| `base_url` | `ACRUXCORE_BASE_URL` | **required** | API base URL (e.g. `https://api.acruxcore.com/api/v1`) |
|
|
199
|
+
| `cache_ttl` | — | `60000` (60s) | Milliseconds before a cached render is stale |
|
|
200
|
+
| `max_cache_size` | — | `500` | Max prompt entries in the in-process LRU cache |
|
|
201
|
+
| `max_retries` | — | `1` | Retries on transient failure (2 total attempts) |
|
|
202
|
+
| `retry_interval` | — | `500` | Milliseconds between retries |
|
|
203
|
+
| `timeout` | — | `30` | Per-request timeout, in seconds |
|
|
204
|
+
|
|
205
|
+
## Error handling
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from acruxcore import AcruxCoreError
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
await hub.render_prompt("my-prompt", "production", vars)
|
|
212
|
+
except AcruxCoreError as err:
|
|
213
|
+
if err.code == "MISSING_VARIABLES":
|
|
214
|
+
print("Missing template variables:", err.body["error"]["missing"])
|
|
215
|
+
elif err.code == "NETWORK_ERROR":
|
|
216
|
+
print("Acrux Core API unreachable. Check base_url.")
|
|
217
|
+
elif err.code == "API_ERROR":
|
|
218
|
+
print(f"Acrux Core API error {err.status_code}")
|
|
219
|
+
raise
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Error codes: `MISSING_API_KEY`, `MISSING_BASE_URL`, `NETWORK_ERROR`, `API_ERROR`,
|
|
223
|
+
`MISSING_VARIABLES`.
|
|
224
|
+
|
|
225
|
+
## Caching
|
|
226
|
+
|
|
227
|
+
- **Cache key:** `{api_key}:{prompt_name}:{alias}` — scoped per team, prompt, alias.
|
|
228
|
+
- **Variables are not part of the key** — different variable values share a slot.
|
|
229
|
+
- **Stale-while-revalidate:** a stale hit returns the cached value immediately and
|
|
230
|
+
fires a background refresh (`asyncio` task).
|
|
231
|
+
- **API unreachable + stale entry:** serves stale and logs a warning.
|
|
232
|
+
- **API unreachable + cold cache:** raises `AcruxCoreError(code="NETWORK_ERROR")`.
|
|
233
|
+
|
|
234
|
+
## Method parity with the TypeScript SDK
|
|
235
|
+
|
|
236
|
+
| TypeScript | Python |
|
|
237
|
+
|------------|--------|
|
|
238
|
+
| `renderPrompt(name, alias, vars)` | `render_prompt(name, alias, variables)` |
|
|
239
|
+
| `chat({...})` | `chat(model, messages, *, ...)` |
|
|
240
|
+
| `chat({stream: true})` | `chat(..., stream=True)` → async iterator |
|
|
241
|
+
| `runToolLoop({...})` | `run_tool_loop(model, messages, dispatch, *, ...)` |
|
|
242
|
+
| `trace(input)` | `trace(input)` |
|
|
243
|
+
| `submitFeedback({...})` | `submit_feedback(trace_id, *, ...)` |
|
|
244
|
+
| `updateFeedback({...})` | `update_feedback(trace_id, feedback_id, *, ...)` |
|
|
245
|
+
| `getTrace(id)` | `get_trace(trace_id)` |
|
|
246
|
+
| `listTraces({...})` | `list_traces(*, ...)` |
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# acruxcore (Python)
|
|
2
|
+
|
|
3
|
+
Async Python SDK for Acrux Core. Fetch rendered prompts at runtime, call the AI
|
|
4
|
+
gateway, run client-side tool loops, and report/read traces — full feature
|
|
5
|
+
parity with the [TypeScript SDK](https://www.npmjs.com/package/@acruxcoreai/sdk),
|
|
6
|
+
with a Pythonic `async`/`await` API.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install acruxcore
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Python 3.9+. Depends only on [`httpx`](https://www.python-httpx.org/).
|
|
15
|
+
|
|
16
|
+
> **Using Node or TypeScript instead?** Install the JavaScript SDK from npm:
|
|
17
|
+
> `npm install @acruxcoreai/sdk` — see
|
|
18
|
+
> [`@acruxcoreai/sdk` on npm](https://www.npmjs.com/package/@acruxcoreai/sdk).
|
|
19
|
+
|
|
20
|
+
## Quickstart
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import asyncio
|
|
24
|
+
from acruxcore import AcruxCore
|
|
25
|
+
|
|
26
|
+
async def main():
|
|
27
|
+
async with AcruxCore(
|
|
28
|
+
api_key="...", # or env ACRUXCORE_API_KEY
|
|
29
|
+
base_url="https://api.acruxcore.com/api/v1", # or env ACRUXCORE_BASE_URL
|
|
30
|
+
) as hub:
|
|
31
|
+
result = await hub.render_prompt("summarise-article", "production", {"article": "..."})
|
|
32
|
+
print(result.messages)
|
|
33
|
+
|
|
34
|
+
asyncio.run(main())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`AcruxCore` owns an `httpx.AsyncClient`, so use it as an async context manager
|
|
38
|
+
(`async with`) or call `await hub.aclose()` when done. Create one instance at
|
|
39
|
+
startup and reuse it — the render cache is a process-wide singleton.
|
|
40
|
+
|
|
41
|
+
## Chat
|
|
42
|
+
|
|
43
|
+
`chat()` is a single, non-looping call to the gateway's OpenAI-compatible
|
|
44
|
+
`POST /gateway/chat/completions`. It routes to the right provider, prices the
|
|
45
|
+
call, and records a trace server-side.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
r = await hub.chat("gpt-4o-mini", [{"role": "user", "content": "Say hi in one word."}])
|
|
49
|
+
print(r.content) # 'Hello!'
|
|
50
|
+
print(r.finish_reason) # 'stop'
|
|
51
|
+
print(r.usage) # ChatUsage(prompt_tokens=..., completion_tokens=..., total_tokens=...)
|
|
52
|
+
print(r.gateway) # GatewayCallMeta(request_id=..., provider=..., cost_usd=..., cache=...)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Pass `tools=` / `tool_refs=` / `tool_choice=` just like the raw endpoint. If the
|
|
56
|
+
model calls a tool, `chat()` hands it back raw on `r.message["tool_calls"]` — it
|
|
57
|
+
never dispatches. Use `run_tool_loop()` for that.
|
|
58
|
+
|
|
59
|
+
## Streaming
|
|
60
|
+
|
|
61
|
+
Pass `stream=True` to get an async iterator of chunks:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
async for chunk in await hub.chat("gpt-4o-mini", messages, stream=True):
|
|
65
|
+
print(chunk.delta.get("content", ""), end="", flush=True)
|
|
66
|
+
if chunk.finish_reason:
|
|
67
|
+
print(f"\n(done: {chunk.finish_reason})")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Each `chunk` mirrors one `chat.completion.chunk` SSE frame (`id`, `model`,
|
|
71
|
+
`delta`, `finish_reason`); iteration ends when the gateway sends `data: [DONE]`.
|
|
72
|
+
|
|
73
|
+
## Tools
|
|
74
|
+
|
|
75
|
+
`render_prompt()` returns `RenderResult(messages, tools)` — `tools` are any Tool
|
|
76
|
+
Catalog tools attached to that prompt version, already in OpenAI shape. Feed
|
|
77
|
+
them into `run_tool_loop()` to drive the whole tool-calling round-trip:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
async def dispatch(name: str, args: dict):
|
|
81
|
+
if name == "get_weather":
|
|
82
|
+
return {"tempC": 18, "condition": "cloudy"}
|
|
83
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
84
|
+
|
|
85
|
+
result = await hub.run_tool_loop(
|
|
86
|
+
model="gpt-4o",
|
|
87
|
+
messages=render.messages,
|
|
88
|
+
dispatch=dispatch,
|
|
89
|
+
tools=render.tools,
|
|
90
|
+
)
|
|
91
|
+
print(result.content) # final assistant text
|
|
92
|
+
print(result.messages) # full transcript, incl. tool calls/results
|
|
93
|
+
print(result.iterations) # number of model round-trips
|
|
94
|
+
print(result.trace_id) # trace covering every round-trip + tool dispatch
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`run_tool_loop()` stops when the model responds without calling a tool, or after
|
|
98
|
+
`max_iterations` round-trips (default 10; `result.stopped_at_limit` is `True`
|
|
99
|
+
then). When the model requests several tools in one turn they are dispatched
|
|
100
|
+
**concurrently** (`asyncio.gather`); results are appended in call order, so
|
|
101
|
+
`dispatch` must be safe to run in parallel. A `dispatch` that raises is not
|
|
102
|
+
caught — wrap it yourself if you want a tool failure reported back to the model
|
|
103
|
+
as a tool-result message instead of aborting the loop.
|
|
104
|
+
|
|
105
|
+
The loop auto-reports one trace: the gateway records an `llm` span per
|
|
106
|
+
round-trip, and the SDK adds a `tool` span per `dispatch` call, threaded into the
|
|
107
|
+
same trace via the `x-trace-id` header. Turn it off with `trace=False`, or attach
|
|
108
|
+
to an existing trace with `trace={"trace_id": "..."}`.
|
|
109
|
+
|
|
110
|
+
## Reporting traces
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from datetime import datetime, timezone
|
|
114
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
115
|
+
|
|
116
|
+
res = await hub.trace({
|
|
117
|
+
"name": "support-agent-run",
|
|
118
|
+
"spans": [
|
|
119
|
+
{"spanId": "s1", "name": "gpt-4o-mini", "kind": "llm", "startTime": now, "endTime": now,
|
|
120
|
+
"model": "gpt-4o-mini", "usage": {"promptTokens": 120, "completionTokens": 40, "totalTokens": 160}},
|
|
121
|
+
{"spanId": "s2", "parentSpanId": "s1", "name": "search_docs", "kind": "tool",
|
|
122
|
+
"startTime": now, "attributes": {"query": "refunds"}},
|
|
123
|
+
],
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
# Append another span to the same trace later:
|
|
127
|
+
await hub.trace({"traceId": res.trace_id, "spans": [
|
|
128
|
+
{"spanId": "s3", "parentSpanId": "s1", "name": "finalize", "kind": "chain", "startTime": now}]})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`kind` is one of `llm | tool | retrieval | embedding | agent | chain | other`;
|
|
132
|
+
`status` is `ok | error | unset`. `input`/`output` are stored only when your team
|
|
133
|
+
has payload capture on (or you pass `capturePayloads: True`). Up to 200 spans per
|
|
134
|
+
call. Span keys are camelCase (`spanId`, `parentSpanId`, `startTime`) because they
|
|
135
|
+
are sent to the API verbatim.
|
|
136
|
+
|
|
137
|
+
## Feedback
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
fb = await hub.submit_feedback(
|
|
141
|
+
trace_id,
|
|
142
|
+
rating=-1, # -1..5
|
|
143
|
+
label="wrong_answer",
|
|
144
|
+
comment="The tool call missed relevant docs.",
|
|
145
|
+
source="end_user", # 'user' | 'developer' | 'end_user' | 'api'
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
await hub.submit_feedback(trace_id, span_id="s1", rating=5) # scope to one span
|
|
149
|
+
|
|
150
|
+
# Edit later (author only). Pass a value to change, None to clear, omit to keep:
|
|
151
|
+
await hub.update_feedback(trace_id, fb.id, rating=1)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
At least one of `rating` / `label` / `comment` is required per call.
|
|
155
|
+
|
|
156
|
+
## Reading traces back
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
detail = await hub.get_trace(trace_id)
|
|
160
|
+
print(detail.trace.status, detail.trace.total_cost_usd, detail.trace.total_tokens)
|
|
161
|
+
print(detail.spans[0].model, detail.spans[0].latency_ms)
|
|
162
|
+
|
|
163
|
+
page = await hub.list_traces(session_id="tokyo-trip-plan-01", limit=10)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Configuration
|
|
167
|
+
|
|
168
|
+
| Argument | Environment Variable | Default | Description |
|
|
169
|
+
|----------|---------------------|---------|-------------|
|
|
170
|
+
| `api_key` | `ACRUXCORE_API_KEY` | **required** | Your Acrux Core API key |
|
|
171
|
+
| `base_url` | `ACRUXCORE_BASE_URL` | **required** | API base URL (e.g. `https://api.acruxcore.com/api/v1`) |
|
|
172
|
+
| `cache_ttl` | — | `60000` (60s) | Milliseconds before a cached render is stale |
|
|
173
|
+
| `max_cache_size` | — | `500` | Max prompt entries in the in-process LRU cache |
|
|
174
|
+
| `max_retries` | — | `1` | Retries on transient failure (2 total attempts) |
|
|
175
|
+
| `retry_interval` | — | `500` | Milliseconds between retries |
|
|
176
|
+
| `timeout` | — | `30` | Per-request timeout, in seconds |
|
|
177
|
+
|
|
178
|
+
## Error handling
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from acruxcore import AcruxCoreError
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
await hub.render_prompt("my-prompt", "production", vars)
|
|
185
|
+
except AcruxCoreError as err:
|
|
186
|
+
if err.code == "MISSING_VARIABLES":
|
|
187
|
+
print("Missing template variables:", err.body["error"]["missing"])
|
|
188
|
+
elif err.code == "NETWORK_ERROR":
|
|
189
|
+
print("Acrux Core API unreachable. Check base_url.")
|
|
190
|
+
elif err.code == "API_ERROR":
|
|
191
|
+
print(f"Acrux Core API error {err.status_code}")
|
|
192
|
+
raise
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Error codes: `MISSING_API_KEY`, `MISSING_BASE_URL`, `NETWORK_ERROR`, `API_ERROR`,
|
|
196
|
+
`MISSING_VARIABLES`.
|
|
197
|
+
|
|
198
|
+
## Caching
|
|
199
|
+
|
|
200
|
+
- **Cache key:** `{api_key}:{prompt_name}:{alias}` — scoped per team, prompt, alias.
|
|
201
|
+
- **Variables are not part of the key** — different variable values share a slot.
|
|
202
|
+
- **Stale-while-revalidate:** a stale hit returns the cached value immediately and
|
|
203
|
+
fires a background refresh (`asyncio` task).
|
|
204
|
+
- **API unreachable + stale entry:** serves stale and logs a warning.
|
|
205
|
+
- **API unreachable + cold cache:** raises `AcruxCoreError(code="NETWORK_ERROR")`.
|
|
206
|
+
|
|
207
|
+
## Method parity with the TypeScript SDK
|
|
208
|
+
|
|
209
|
+
| TypeScript | Python |
|
|
210
|
+
|------------|--------|
|
|
211
|
+
| `renderPrompt(name, alias, vars)` | `render_prompt(name, alias, variables)` |
|
|
212
|
+
| `chat({...})` | `chat(model, messages, *, ...)` |
|
|
213
|
+
| `chat({stream: true})` | `chat(..., stream=True)` → async iterator |
|
|
214
|
+
| `runToolLoop({...})` | `run_tool_loop(model, messages, dispatch, *, ...)` |
|
|
215
|
+
| `trace(input)` | `trace(input)` |
|
|
216
|
+
| `submitFeedback({...})` | `submit_feedback(trace_id, *, ...)` |
|
|
217
|
+
| `updateFeedback({...})` | `update_feedback(trace_id, feedback_id, *, ...)` |
|
|
218
|
+
| `getTrace(id)` | `get_trace(trace_id)` |
|
|
219
|
+
| `listTraces({...})` | `list_traces(*, ...)` |
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "acruxcore"
|
|
7
|
+
version = "0.4.1"
|
|
8
|
+
description = "Async Python SDK for Acrux Core — runtime prompt render, gateway chat, tool loops, traces, and feedback"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Acrux Core" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"acruxcore",
|
|
15
|
+
"prompt-management",
|
|
16
|
+
"llm",
|
|
17
|
+
"ai-gateway",
|
|
18
|
+
"observability",
|
|
19
|
+
"tracing",
|
|
20
|
+
"sdk",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.9",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"pytest-asyncio>=0.23",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/talhaanwarch/agenticprompthub/tree/main/packages/sdk-python#readme"
|
|
44
|
+
Repository = "https://github.com/talhaanwarch/agenticprompthub"
|
|
45
|
+
Issues = "https://github.com/talhaanwarch/agenticprompthub/issues"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/acruxcore"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
asyncio_mode = "auto"
|
|
52
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Async Python SDK for Acrux Core.
|
|
2
|
+
|
|
3
|
+
Runtime prompt render, gateway chat (with streaming), client-side tool loops,
|
|
4
|
+
trace reporting, and feedback — full parity with the TypeScript SDK.
|
|
5
|
+
|
|
6
|
+
Example::
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from acruxcore import AcruxCore
|
|
10
|
+
|
|
11
|
+
async def main():
|
|
12
|
+
async with AcruxCore(api_key=..., base_url="http://localhost:3000/api/v1") as hub:
|
|
13
|
+
result = await hub.render_prompt("greeting", "production", {"name": "Alice"})
|
|
14
|
+
print(result.messages)
|
|
15
|
+
|
|
16
|
+
asyncio.run(main())
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .client import AcruxCore, AsyncChatStream
|
|
20
|
+
from .errors import (
|
|
21
|
+
API_ERROR,
|
|
22
|
+
MISSING_API_KEY,
|
|
23
|
+
MISSING_BASE_URL,
|
|
24
|
+
MISSING_VARIABLES,
|
|
25
|
+
NETWORK_ERROR,
|
|
26
|
+
AcruxCoreError,
|
|
27
|
+
)
|
|
28
|
+
from .types import (
|
|
29
|
+
ChatChunk,
|
|
30
|
+
ChatResult,
|
|
31
|
+
ChatUsage,
|
|
32
|
+
FeedbackResult,
|
|
33
|
+
GatewayCallMeta,
|
|
34
|
+
GetTraceResult,
|
|
35
|
+
IngestSpan,
|
|
36
|
+
ListTracesResult,
|
|
37
|
+
Message,
|
|
38
|
+
RenderResult,
|
|
39
|
+
RunToolLoopResult,
|
|
40
|
+
ToolCall,
|
|
41
|
+
ToolChoice,
|
|
42
|
+
ToolDefinition,
|
|
43
|
+
ToolRef,
|
|
44
|
+
TraceInput,
|
|
45
|
+
TraceResult,
|
|
46
|
+
TraceSpan,
|
|
47
|
+
TraceSummary,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__version__ = "0.4.1"
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"AcruxCore",
|
|
54
|
+
"AsyncChatStream",
|
|
55
|
+
"AcruxCoreError",
|
|
56
|
+
"API_ERROR",
|
|
57
|
+
"MISSING_API_KEY",
|
|
58
|
+
"MISSING_BASE_URL",
|
|
59
|
+
"MISSING_VARIABLES",
|
|
60
|
+
"NETWORK_ERROR",
|
|
61
|
+
"ChatChunk",
|
|
62
|
+
"ChatResult",
|
|
63
|
+
"ChatUsage",
|
|
64
|
+
"FeedbackResult",
|
|
65
|
+
"GatewayCallMeta",
|
|
66
|
+
"GetTraceResult",
|
|
67
|
+
"IngestSpan",
|
|
68
|
+
"ListTracesResult",
|
|
69
|
+
"Message",
|
|
70
|
+
"RenderResult",
|
|
71
|
+
"RunToolLoopResult",
|
|
72
|
+
"ToolCall",
|
|
73
|
+
"ToolChoice",
|
|
74
|
+
"ToolDefinition",
|
|
75
|
+
"ToolRef",
|
|
76
|
+
"TraceInput",
|
|
77
|
+
"TraceResult",
|
|
78
|
+
"TraceSpan",
|
|
79
|
+
"TraceSummary",
|
|
80
|
+
"__version__",
|
|
81
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Module-level LRU cache for rendered prompts (stale-while-revalidate).
|
|
2
|
+
|
|
3
|
+
Mirrors the TypeScript SDK's ``cache.ts``: a process-wide singleton so that a
|
|
4
|
+
stale entry can be served while a background refresh is in flight. ``max_size``
|
|
5
|
+
is honoured only on the first :func:`get_cache` call — reuse one client.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .types import RenderResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CacheEntry:
|
|
19
|
+
"""One cache slot: the rendered value plus the epoch-ms it was fetched at."""
|
|
20
|
+
|
|
21
|
+
value: RenderResult
|
|
22
|
+
fetched_at: float # epoch milliseconds
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _LRUCache:
|
|
26
|
+
"""A tiny LRU cache keyed by string, bounded by ``max_size`` entries."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, max_size: int) -> None:
|
|
29
|
+
self._max = max_size
|
|
30
|
+
self._store: "OrderedDict[str, CacheEntry]" = OrderedDict()
|
|
31
|
+
|
|
32
|
+
def get(self, key: str) -> Optional[CacheEntry]:
|
|
33
|
+
entry = self._store.get(key)
|
|
34
|
+
if entry is not None:
|
|
35
|
+
self._store.move_to_end(key) # mark most-recently-used
|
|
36
|
+
return entry
|
|
37
|
+
|
|
38
|
+
def set(self, key: str, entry: CacheEntry) -> None:
|
|
39
|
+
self._store[key] = entry
|
|
40
|
+
self._store.move_to_end(key)
|
|
41
|
+
while len(self._store) > self._max:
|
|
42
|
+
self._store.popitem(last=False) # evict least-recently-used
|
|
43
|
+
|
|
44
|
+
def clear(self) -> None:
|
|
45
|
+
self._store.clear()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_cache: Optional[_LRUCache] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_cache(max_size: int) -> _LRUCache:
|
|
52
|
+
"""Return the process-wide LRU cache, creating it on the first call.
|
|
53
|
+
|
|
54
|
+
:param max_size: Maximum entries. Effective only on the first call.
|
|
55
|
+
:returns: The shared cache instance.
|
|
56
|
+
"""
|
|
57
|
+
global _cache
|
|
58
|
+
if _cache is None:
|
|
59
|
+
_cache = _LRUCache(max_size)
|
|
60
|
+
return _cache
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _reset_cache_for_testing() -> None:
|
|
64
|
+
"""Reset the singleton. FOR TESTING ONLY."""
|
|
65
|
+
global _cache
|
|
66
|
+
_cache = None
|