Skip to main content

acruxcore (Python)

Async Python SDK for Acrux Core. Fetch rendered prompts at runtime, call the AI gateway, run client-side tool loops, and report/read traces — full feature parity with the TypeScript SDK, with a Pythonic async/await API.

Installation

pip install acruxcore

Requires Python 3.9+. Depends only on httpx.

Using Node or TypeScript instead? Install the JavaScript SDK from npm: npm install @acruxcoreai/sdk — see @acruxcoreai/sdk on npm.

See CHANGELOG.md for release notes.

Quickstart

import asyncio
from acruxcore import AcruxCore

async def main():
    async with AcruxCore(
        api_key="...",                               # or env ACRUXCORE_API_KEY
        base_url="https://api.acruxcore.com/api/v1",  # or env ACRUXCORE_BASE_URL
    ) as hub:
        result = await hub.prompts.render("summarise-article", "production", {"article": "..."})
        print(result.messages)

asyncio.run(main())

AcruxCore owns an httpx.AsyncClient, so use it as an async context manager (async with) or call await hub.gateway.aclose() when done. Create one instance at startup and reuse it — the render cache is a process-wide singleton.

Chat

gateway.chat() is a single, non-looping call to the gateway's OpenAI-compatible POST /gateway/chat/completions. It routes to the right provider, prices the call, and records a trace server-side.

r = await hub.gateway.chat("gpt-4o-mini", [{"role": "user", "content": "Say hi in one word."}])
print(r.content)        # 'Hello!'
print(r.finish_reason)  # 'stop'
print(r.usage)          # ChatUsage(prompt_tokens=..., completion_tokens=..., total_tokens=...)
print(r.gateway)        # GatewayCallMeta(request_id=..., provider=..., cost_usd=..., cache=...)

Pass tools= / tool_refs= / tool_choice= just like the raw endpoint. If the model calls a tool, gateway.chat() hands it back raw on r.message["tool_calls"] — it never dispatches. Use gateway.run_tool_loop() for that.

Streaming

Use gateway.stream() to get an async iterator of chunks:

async for chunk in await hub.gateway.stream("gpt-4o-mini", messages):
    print(chunk.delta.get("content", ""), end="", flush=True)
    if chunk.finish_reason:
        print(f"\n(done: {chunk.finish_reason})")

Each chunk mirrors one chat.completion.chunk SSE frame (id, model, delta, finish_reason); iteration ends when the gateway sends data: [DONE].

Tools

Decorate a function with @acrux.tool and hand it to the loop. The name, the model-facing description and the parameter schema all come from the function, so there is nothing to keep in sync by hand:

import httpx
from acruxcore import AcruxCore, acrux

@acrux.tool
async def get_weather(city: str) -> dict:
    """Get the current weather for a city.

    Args:
        city: City name, e.g. 'Lahore'.
    """
    async with httpx.AsyncClient() as http:
        res = await http.get(f"https://wttr.in/{city}", params={"format": "j1"})
    current = res.json()["current_condition"][0]
    return {"city": city, "temp_c": int(current["temp_C"])}


async with AcruxCore() as hub:
    result = await hub.gateway.run_tool_loop(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": "Should I run in Lahore this evening?"}],
        tools=[get_weather],
    )
    print(result.content)      # final assistant text
    print(result.messages)     # full transcript, incl. tool calls/results
    print(result.iterations)   # number of model round-trips
    print(result.trace_id)     # trace covering every round-trip + tool call

The decorator is pure: it attaches a spec to the function and returns it unchanged, so await get_weather(city="London") still works in a test.

What the decorator derives

These rules are the SDK's contract, so they are worth knowing exactly:

  • Name — the function name.
  • Description — the docstring's first paragraph. A function with no docstring sends no description, which leaves whatever your team wrote in the dashboard in place. Write one and code owns it: every sync overwrites the dashboard's text. Pick per tool which side owns the wording.
  • Parameter descriptions — the Args: block, Google style.
  • Required — every parameter without a default.
  • Supported hintsstr, int, float, bool, list[T], dict, Optional[T], Literal[...], and Enum subclasses. Anything else raises ToolSchemaError at decoration time — at import, not mid-run.

@acrux.tool(parameters={...}) is the escape hatch: pass a JSON Schema and the derivation is skipped entirely.

@acrux.tool(parameters={"type": "object", "properties": {"table": {"type": "string"}}, "required": ["table"]})
async def count_rows(table: str) -> dict:
    ...

On Python 3.9 a tool signature must spell an optional parameter Optional[int] rather than int | None; the X | Y form in an annotation is 3.10+.

The catalog round-trip

On the first call, gateway.run_tool_loop syncs each decorated tool into the Tool Catalog and then passes it to the model as a tool_refs entry rather than as an inline schema. So the schema the model sees is the one the catalog holds, the dashboard shows a version history for a tool defined in code, and every tool span records the exact version that ran. The sync is idempotent and cached per process: an unchanged tool costs one request per process, a changed one commits a new version and moves its alias. Pass sync=False when a deploy step already synced them.

Catalog tools you didn't decorate

A tool whose catalog version has an http executor needs no local code at all. Name it in tool_refs= and the platform calls the endpoint, writes the tool span with the real payloads, and hands the result back to the loop:

result = await hub.gateway.run_tool_loop(
    model="gpt-4o-mini",
    messages=messages,
    tool_refs=[{"name": "search_orders", "alias": "production"}],
)

dispatch is still there, and is what you need for two cases: raw OpenAI-shaped dicts passed as tool_defs=, and a tool_refs entry with a client executor you have not decorated. Something has to run a client tool, so if neither a decorated function nor dispatch can, the loop raises MISSING_DISPATCH before the first model call — the failure costs no tokens.

async def dispatch(name: str, args: dict):
    if name == "get_weather":
        return await fetch_weather_from_your_provider(args["city"])
    raise ValueError(f"Unknown tool: {name}")

result = await hub.gateway.run_tool_loop(
    model="gpt-4o-mini", messages=messages, tool_defs=raw_defs, dispatch=dispatch
)

Prompt-attached tools arrive this way too: prompts.render() returns RenderResult(messages, tools) where tools are the version's attached catalog tools in OpenAI shape — those go in tool_defs=.

The loop's behaviour

gateway.run_tool_loop() stops when the model responds without calling a tool, or after max_iterations round-trips (default 10; result.stopped_at_limit is True then). When the model requests several tools in one turn they run concurrently (asyncio.gather); results are appended in call order, so a tool body must be safe to run in parallel. A tool that raises is not caught — wrap it yourself if you want a tool failure reported back to the model as a tool-result message instead of aborting the loop.

The loop auto-reports one trace: the gateway records an llm span per round-trip, and the SDK adds a tool span per client-side call, threaded into the same trace via the x-trace-id header. Tools that ran on the platform get their span from the platform, so they land in the same waterfall without being reported twice. Turn tracing off with trace=False, or attach to an existing trace with trace={"trace_id": "..."}.

Catalog access without the loop

hub.tools reaches the catalog directly — useful in a deploy step, or when you drive the model yourself:

await hub.tools.sync([get_weather], on_conflict="error")   # reconcile at deploy time
resolved = await hub.tools.resolve([{"name": "search_orders"}])
out = await hub.tools.execute(resolved[0].tool_id, {"query": "refunds"})

sync returns, per tool, the version it landed on and whether this call committed it. on_conflict="error" raises when a commit supersedes a version someone edited in the dashboard; the default warns instead, so a dashboard experiment can never block a deploy.

Bring your own provider (BYO)

gateway.chat() and gateway.run_tool_loop() can skip our gateway entirely and call your model provider's OpenAI-compatible endpoint directly — pass a provider= argument (or set one as a client-level default):

result = await hub.gateway.chat(
    "llama-3.1-70b-versatile",
    [{"role": "user", "content": "Hello!"}],
    provider={"base_url": "https://api.groq.com/openai/v1", "api_key": os.environ["GROQ_API_KEY"]},
)

This skips the extra network hop through the gateway, and provider["api_key"] is sent only to provider["base_url"] — it never reaches acruxcore's servers. Tracing and prompt lineage still work: a BYO call auto-reports its own llm span (tokens, latency, model, payloads — dollar cost isn't computed for BYO spans yet) plus any tool spans, and passing prompt_version_id (from prompts.render()'s version_id/version_number) still links the trace back to the exact prompt version that produced it. In a BYO gateway.run_tool_loop(), each round's llm span is reported as soon as that round returns rather than batched to the end, so a long loop is observable while it runs — and a platform-executed (http) tool's span nests under the round that called it.

A non-HTTPS provider["base_url"] warns once per URL, the same way a non-HTTPS platform base_url already did: the BYO path sends your provider key as a bearer token to that URL, so plain http:// to a non-loopback host would send it in cleartext. Loopback URLs (http://localhost:11434 and friends) stay quiet.

The gateway path stays untraced by default, because the gateway records its own span there. You can opt in with trace=True or trace={"trace_id": ..., "session_id": ...} — useful for threading several manual gateway.chat() calls into one trace. Be aware that on the gateway path this always records a second llm span for the same completion (under an id of its own, next to the one the gateway already wrote), so the completion shows up twice: in the gateway's trace, or in yours plus the gateway's if you pass your own trace_id.

Reporting traces

from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()

res = await hub.traces.ingest({
    "name": "support-agent-run",
    "spans": [
        {"spanId": "s1", "name": "gpt-4o-mini", "kind": "llm", "startTime": now, "endTime": now,
         "model": "gpt-4o-mini", "usage": {"promptTokens": 120, "completionTokens": 40, "totalTokens": 160}},
        {"spanId": "s2", "parentSpanId": "s1", "name": "search_docs", "kind": "tool",
         "startTime": now, "attributes": {"query": "refunds"}},
    ],
})

# Append another span to the same trace later:
await hub.traces.ingest({"traceId": res.trace_id, "spans": [
    {"spanId": "s3", "parentSpanId": "s1", "name": "finalize", "kind": "chain", "startTime": now}]})

kind is one of llm | tool | retrieval | embedding | agent | chain | other; status is ok | error | unset. input/output are stored only when your team has payload capture on (or you pass capturePayloads: True). Up to 200 spans per call. Span keys are camelCase (spanId, parentSpanId, startTime) because they are sent to the API verbatim.

When automatic traces are sent

traces.ingest() above is awaited — you get the trace_id back. The automatic reports from gateway.chat(), streaming gateway.stream() and gateway.run_tool_loop() are not: they go onto a background queue so a model call never waits on telemetry. There is no batching timer, so they aren't delayed either — an idle client sends each span as soon as it records it, and spans group into one request only while another is already in flight.

One situation needs an extra line:

# Reading the traces API back straight after a call
result = await hub.gateway.chat(model, messages)
await hub.gateway.flush()                      # wait for the spans to be written
detail = await hub.traces.get(result.gateway.trace_id)

gateway.aclose() flushes before closing the HTTP client, so async with AcruxCore(...) already handles shutdown. A script that finishes and exits needs nothing: an atexit hook drains the queue on a fresh event loop. The SDK installs no SIGINT/SIGTERM handlers — signal disposition belongs to your application — so a process killed by a signal drops whatever spans were buffered.

Feedback

fb = await hub.traces.submit_feedback(
    trace_id,
    rating=-1,                # -1..5
    label="wrong_answer",
    comment="The tool call missed relevant docs.",
    source="end_user",        # 'user' | 'developer' | 'end_user' | 'api'
)

await hub.traces.submit_feedback(trace_id, span_id="s1", rating=5)  # scope to one span

# Edit later (author only). Pass a value to change, None to clear, omit to keep:
await hub.traces.update_feedback(trace_id, fb.id, rating=1)

At least one of rating / label / comment is required per call.

Reading traces back

detail = await hub.traces.get(trace_id)
print(detail.trace.status, detail.trace.total_cost_usd, detail.trace.total_tokens)
print(detail.spans[0].model, detail.spans[0].latency_ms)

page = await hub.traces.list(session_id="tokyo-trip-plan-01", limit=10)

Configuration

Argument Environment Variable Default Description
api_key ACRUXCORE_API_KEY required Your Acrux Core API key
base_url ACRUXCORE_BASE_URL required API base URL (e.g. https://api.acruxcore.com/api/v1)
cache_ttl 60000 (60s) Milliseconds before a cached render is stale. 0 disables caching
max_cache_size 500 Max prompt entries in the in-process LRU cache
max_retries 1 Retries on transient failure (2 total attempts)
retry_interval 500 Milliseconds between retries
timeout 30 Per-request timeout, in seconds

Error handling

from acruxcore import AcruxCoreError

try:
    await hub.prompts.render("my-prompt", "production", vars)
except AcruxCoreError as err:
    if err.code == "MISSING_VARIABLES":
        print("Missing template variables:", err.body["error"]["missing"])
    elif err.code == "NETWORK_ERROR":
        print("Acrux Core API unreachable. Check base_url.")
    elif err.code == "API_ERROR":
        print(f"Acrux Core API error {err.status_code}")
    raise

Error codes: MISSING_API_KEY, MISSING_BASE_URL, NETWORK_ERROR, API_ERROR, MISSING_VARIABLES.

Caching

  • Cache key: {api_key}:{prompt_name}:{alias}:{variables_hash} — scoped per team, prompt, alias, and set of variables, so new variables always re-render. The hash ignores key order, so {"a": 1, "b": 2} and {"b": 2, "a": 1} share one entry.
  • Turning it off: cache_ttl=0 disables caching completely — every prompts.render call hits the API and nothing is stored (so the serve-stale behaviour below no longer applies).
  • Stale-while-revalidate: a stale hit returns the cached value immediately and fires a background refresh (asyncio task).
  • API unreachable + stale entry: serves stale and logs a warning.
  • API unreachable + cold cache: raises AcruxCoreError(code="NETWORK_ERROR").

Method parity with the TypeScript SDK

TypeScript Python
renderPrompt(name, alias, vars) prompts.render(name, alias, variables)
chat({...}) gateway.chat(model, messages, *, ...)
chat({stream: true}) gateway.stream(model, messages, *, ...) → async iterator
runToolLoop({...}) gateway.run_tool_loop(model, messages, *, tools=, tool_defs=, tool_refs=, dispatch=None, sync=True, ...)
chat({provider: {baseUrl, apiKey}}) / runToolLoop({provider}) — BYO gateway.chat(..., provider={"base_url", "api_key"}) / gateway.run_tool_loop(..., provider=...) — BYO
acrux.tool({name, parameters}, handler) @acrux.tool (or @acrux.tool(parameters={...}))
hub.tools.sync(tools, {onConflict}) hub.tools.sync(tools, on_conflict=...)
hub.tools.resolve(refs) hub.tools.resolve(refs)
hub.tools.execute(toolId, args, {...}) hub.tools.execute(tool_id, args, ...)
trace(input) traces.ingest(input)
submitFeedback({...}) traces.submit_feedback(trace_id, *, ...)
updateFeedback({...}) traces.update_feedback(trace_id, feedback_id, *, ...)
getTrace(id) traces.get(trace_id)
listTraces({...}) traces.list(*, ...)

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

acruxcore-0.7.0.tar.gz (122.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

acruxcore-0.7.0-py3-none-any.whl (74.4 kB view details)

Uploaded Python 3

File details

Details for the file acruxcore-0.7.0.tar.gz.

File metadata

  • Download URL: acruxcore-0.7.0.tar.gz
  • Upload date:
  • Size: 122.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.6

File hashes

Hashes for acruxcore-0.7.0.tar.gz
Algorithm Hash digest
SHA256 a52cb86e0a2e6810307e8badfd3cca67ccb882fd4560adfe9e7421324f0f2618
MD5 aed09d2d0b2638eec62bc88fe4b1cf62
BLAKE2b-256 970dadcd2abf3873c05bf0d877eaf8a8c7f5b79ec11e7b90f41f5ecb82d6f2bc

See more details on using hashes here.

File details

Details for the file acruxcore-0.7.0-py3-none-any.whl.

File metadata

  • Download URL: acruxcore-0.7.0-py3-none-any.whl
  • Upload date:
  • Size: 74.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.6

File hashes

Hashes for acruxcore-0.7.0-py3-none-any.whl
Algorithm Hash digest
SHA256 812fa326ed5d36a4d58e6610f98717fb9496bf915cd7481d5f2158dd52df0e24
MD5 0d383e0c3974f02774f8eca708c2ae81
BLAKE2b-256 4ee1f28ee7de0babf2cbadc46176734bac3aa28764736e18fd97a52baae3e4fd

See more details on using hashes here.

Release history Release notifications | RSS feed

0.10.0

2 files

0.9.0

2 files

0.8.0

2 files

0.7.1

2 files

This release

0.7.0 This release

2 files

0.6.7

2 files

0.6.6

2 files

0.6.5

2 files

0.6.0

2 files

0.5.0

2 files

0.4.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page