avenza 1.0.0__py3-none-any.whl

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.
avenza/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ Avenza Python SDK
3
+
4
+ Instrument AI agents in one line. Cost, value, and SLO tracking — automatically.
5
+
6
+ Quickstart:
7
+ from avenza import Agent
8
+
9
+ agent = Agent(name='Invoice Bot', risk_tier='T2')
10
+
11
+ with agent.run() as run:
12
+ result = process(data)
13
+ run.success = result.ok
14
+ run.log_value('task_completed', quantity=1, unit_value_usd=1.50)
15
+
16
+ Auto-instrumentation is active by default — if you're using the official Anthropic,
17
+ OpenAI, or Gemini client, token usage is captured without any additional code.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from .agent import Agent
22
+ from .exceptions import AvenzaConfigError, AvenzaError
23
+
24
+ try:
25
+ from importlib.metadata import version as _version
26
+ __version__: str = _version("avenza")
27
+ except Exception:
28
+ __version__ = "dev"
29
+
30
+ __all__ = ["Agent", "AvenzaError", "AvenzaConfigError", "__version__"]
avenza/_buffer.py ADDED
@@ -0,0 +1,52 @@
1
+ """
2
+ Offline buffer — persists failed sends to .avenza_buffer.jsonl and retries
3
+ on next SDK init. Designed for intermittent-connectivity environments.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from .client import AvenzaClient
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _DEFAULT_PATH = ".avenza_buffer.jsonl"
18
+
19
+
20
+ class OfflineBuffer:
21
+ def __init__(self, path: str = _DEFAULT_PATH) -> None:
22
+ self._path = path
23
+
24
+ def save(self, path: str, payload: dict[str, Any]) -> None:
25
+ try:
26
+ with open(self._path, "a", encoding="utf-8") as f:
27
+ f.write(json.dumps({"path": path, "payload": payload}) + "\n")
28
+ except OSError:
29
+ pass # Read-only FS or permission error — drop silently
30
+
31
+ def flush(self, client: "AvenzaClient") -> None:
32
+ if not os.path.exists(self._path):
33
+ return
34
+ try:
35
+ with open(self._path, encoding="utf-8") as f:
36
+ lines = f.readlines()
37
+ os.remove(self._path)
38
+ except OSError:
39
+ return
40
+
41
+ flushed = 0
42
+ for line in lines:
43
+ try:
44
+ entry = json.loads(line.strip())
45
+ if entry.get("path") and entry.get("payload") is not None:
46
+ client.post_async(entry["path"], entry["payload"])
47
+ flushed += 1
48
+ except (json.JSONDecodeError, KeyError):
49
+ pass
50
+
51
+ if flushed:
52
+ logger.info("avenza: flushed %d buffered run(s) from offline buffer", flushed)
avenza/_context.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ contextvars-based active-run tracking.
3
+
4
+ Each OS thread and each asyncio task gets its own isolated view of the current
5
+ run, so token capture is always attributed to the correct run even under
6
+ concurrent async tasks or multi-threaded agents.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import contextvars
11
+ from typing import TYPE_CHECKING, Optional
12
+
13
+ if TYPE_CHECKING:
14
+ from .run import RunContext
15
+
16
+ _current_run: contextvars.ContextVar[Optional["RunContext"]] = contextvars.ContextVar(
17
+ "avenza_current_run", default=None
18
+ )
19
+
20
+
21
+ def get_current_run() -> Optional["RunContext"]:
22
+ return _current_run.get()
23
+
24
+
25
+ def set_current_run(run: "RunContext") -> contextvars.Token:
26
+ return _current_run.set(run)
27
+
28
+
29
+ def reset_current_run(token: contextvars.Token) -> None:
30
+ _current_run.reset(token)
@@ -0,0 +1,17 @@
1
+ """
2
+ Auto-instrumentation dispatcher.
3
+
4
+ patch_all() tries each provider's patch. If the provider's SDK is not
5
+ installed, the patch is silently skipped — no error, no warning.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ def patch_all() -> None:
11
+ from .anthropic_patch import patch_anthropic
12
+ from .openai_patch import patch_openai
13
+ from .gemini_patch import patch_gemini
14
+
15
+ patch_anthropic()
16
+ patch_openai()
17
+ patch_gemini()
@@ -0,0 +1,77 @@
1
+ """
2
+ Patches Anthropic's official Python client to capture token usage automatically.
3
+ Wraps both the sync and async message creation methods.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import functools
8
+ import logging
9
+
10
+ from .._context import get_current_run
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _patched = False
15
+
16
+
17
+ def patch_anthropic() -> None:
18
+ global _patched
19
+ if _patched:
20
+ return
21
+
22
+ try:
23
+ import anthropic
24
+ except ImportError:
25
+ return # SDK not installed — nothing to patch, no error
26
+
27
+ try:
28
+ _patch_sync(anthropic)
29
+ _patch_async(anthropic)
30
+ _patched = True
31
+ logger.debug("avenza: anthropic auto-instrumentation active")
32
+ except Exception as exc:
33
+ logger.debug("avenza: anthropic patch failed (non-fatal) — %s", exc)
34
+
35
+
36
+ def _patch_sync(anthropic: object) -> None:
37
+ messages_cls = anthropic.resources.messages.Messages # type: ignore[attr-defined]
38
+ original = messages_cls.create
39
+
40
+ @functools.wraps(original)
41
+ def wrapped(self: object, *args: object, **kwargs: object) -> object:
42
+ response = original(self, *args, **kwargs)
43
+ run = get_current_run()
44
+ if run is not None and hasattr(response, "usage"):
45
+ run._record_auto_tokens(
46
+ provider="anthropic",
47
+ model=str(kwargs.get("model", "unknown")),
48
+ input_tokens=getattr(response.usage, "input_tokens", 0),
49
+ output_tokens=getattr(response.usage, "output_tokens", 0),
50
+ )
51
+ return response
52
+
53
+ messages_cls.create = wrapped
54
+
55
+
56
+ def _patch_async(anthropic: object) -> None:
57
+ try:
58
+ async_cls = anthropic.resources.messages.AsyncMessages # type: ignore[attr-defined]
59
+ except AttributeError:
60
+ return # older version — no async client
61
+
62
+ original_async = async_cls.create
63
+
64
+ @functools.wraps(original_async)
65
+ async def wrapped_async(self: object, *args: object, **kwargs: object) -> object:
66
+ response = await original_async(self, *args, **kwargs)
67
+ run = get_current_run()
68
+ if run is not None and hasattr(response, "usage"):
69
+ run._record_auto_tokens(
70
+ provider="anthropic",
71
+ model=str(kwargs.get("model", "unknown")),
72
+ input_tokens=getattr(response.usage, "input_tokens", 0),
73
+ output_tokens=getattr(response.usage, "output_tokens", 0),
74
+ )
75
+ return response
76
+
77
+ async_cls.create = wrapped_async
@@ -0,0 +1,76 @@
1
+ """
2
+ Patches Google's Generative AI client to capture token usage automatically.
3
+ Works with both google-generativeai (genai) and google-cloud-aiplatform clients.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import functools
8
+ import logging
9
+
10
+ from .._context import get_current_run
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _patched = False
15
+
16
+
17
+ def patch_gemini() -> None:
18
+ global _patched
19
+ if _patched:
20
+ return
21
+
22
+ try:
23
+ _patch_genai()
24
+ _patched = True
25
+ except ImportError:
26
+ pass # Neither SDK installed — skip silently
27
+ except Exception as exc:
28
+ logger.debug("avenza: gemini patch failed (non-fatal) — %s", exc)
29
+
30
+
31
+ def _patch_genai() -> None:
32
+ import google.generativeai as genai # type: ignore[import]
33
+ model_cls = genai.GenerativeModel
34
+
35
+ original_sync = model_cls.generate_content
36
+
37
+ @functools.wraps(original_sync)
38
+ def wrapped_sync(self: object, *args: object, **kwargs: object) -> object:
39
+ response = original_sync(self, *args, **kwargs)
40
+ run = get_current_run()
41
+ if run is not None:
42
+ _capture_gemini_usage(run, response, getattr(self, "model_name", "gemini"))
43
+ return response
44
+
45
+ model_cls.generate_content = wrapped_sync
46
+ logger.debug("avenza: gemini (google-generativeai) auto-instrumentation active")
47
+
48
+ # Async variant
49
+ if hasattr(model_cls, "generate_content_async"):
50
+ original_async = model_cls.generate_content_async
51
+
52
+ @functools.wraps(original_async)
53
+ async def wrapped_async(self: object, *args: object, **kwargs: object) -> object:
54
+ response = await original_async(self, *args, **kwargs)
55
+ run = get_current_run()
56
+ if run is not None:
57
+ _capture_gemini_usage(run, response, getattr(self, "model_name", "gemini"))
58
+ return response
59
+
60
+ model_cls.generate_content_async = wrapped_async
61
+
62
+
63
+ def _capture_gemini_usage(run: object, response: object, model_name: str) -> None:
64
+ # google-generativeai stores token counts in response.usage_metadata
65
+ usage = getattr(response, "usage_metadata", None)
66
+ if usage is None:
67
+ return
68
+ input_tokens = getattr(usage, "prompt_token_count", 0) or 0
69
+ output_tokens = getattr(usage, "candidates_token_count", 0) or 0
70
+ if input_tokens or output_tokens:
71
+ run._record_auto_tokens( # type: ignore[union-attr]
72
+ provider="google",
73
+ model=model_name,
74
+ input_tokens=input_tokens,
75
+ output_tokens=output_tokens,
76
+ )
@@ -0,0 +1,77 @@
1
+ """
2
+ Patches OpenAI's official Python client to capture token usage automatically.
3
+ Handles both sync (Completions.create) and async (AsyncCompletions.create).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import functools
8
+ import logging
9
+
10
+ from .._context import get_current_run
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _patched = False
15
+
16
+
17
+ def patch_openai() -> None:
18
+ global _patched
19
+ if _patched:
20
+ return
21
+
22
+ try:
23
+ import openai
24
+ except ImportError:
25
+ return
26
+
27
+ try:
28
+ _patch_sync(openai)
29
+ _patch_async(openai)
30
+ _patched = True
31
+ logger.debug("avenza: openai auto-instrumentation active")
32
+ except Exception as exc:
33
+ logger.debug("avenza: openai patch failed (non-fatal) — %s", exc)
34
+
35
+
36
+ def _patch_sync(openai: object) -> None:
37
+ completions_cls = openai.resources.chat.completions.Completions # type: ignore[attr-defined]
38
+ original = completions_cls.create
39
+
40
+ @functools.wraps(original)
41
+ def wrapped(self: object, *args: object, **kwargs: object) -> object:
42
+ response = original(self, *args, **kwargs)
43
+ run = get_current_run()
44
+ if run is not None and hasattr(response, "usage") and response.usage is not None:
45
+ run._record_auto_tokens(
46
+ provider="openai",
47
+ model=str(kwargs.get("model", getattr(response, "model", "unknown"))),
48
+ input_tokens=getattr(response.usage, "prompt_tokens", 0),
49
+ output_tokens=getattr(response.usage, "completion_tokens", 0),
50
+ )
51
+ return response
52
+
53
+ completions_cls.create = wrapped
54
+
55
+
56
+ def _patch_async(openai: object) -> None:
57
+ try:
58
+ async_cls = openai.resources.chat.completions.AsyncCompletions # type: ignore[attr-defined]
59
+ except AttributeError:
60
+ return
61
+
62
+ original_async = async_cls.create
63
+
64
+ @functools.wraps(original_async)
65
+ async def wrapped_async(self: object, *args: object, **kwargs: object) -> object:
66
+ response = await original_async(self, *args, **kwargs)
67
+ run = get_current_run()
68
+ if run is not None and hasattr(response, "usage") and response.usage is not None:
69
+ run._record_auto_tokens(
70
+ provider="openai",
71
+ model=str(kwargs.get("model", getattr(response, "model", "unknown"))),
72
+ input_tokens=getattr(response.usage, "prompt_tokens", 0),
73
+ output_tokens=getattr(response.usage, "completion_tokens", 0),
74
+ )
75
+ return response
76
+
77
+ async_cls.create = wrapped_async
avenza/agent.py ADDED
@@ -0,0 +1,180 @@
1
+ """
2
+ Agent — the entry point for instrumenting a single AI agent.
3
+
4
+ agent = Agent(name='Invoice Bot', risk_tier='T2')
5
+
6
+ with agent.run() as run:
7
+ result = do_work()
8
+ run.success = result.ok
9
+ run.log_value('task_completed', quantity=1, unit_value_usd=1.50)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ from typing import Any, Optional
16
+
17
+ from .client import AvenzaClient
18
+ from .exceptions import AvenzaConfigError
19
+ from .run import AgentRefGetter, RunContext
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class Agent:
25
+ """
26
+ Represents a single AI agent registered in Avenza.
27
+
28
+ Parameters
29
+ ----------
30
+ name : Agent display name — used to find-or-create on the platform.
31
+ risk_tier : 'T1' (autonomous), 'T2' (approve-first), or 'T3' (assist-only).
32
+ api_key : Bearer token with sdk scope. Falls back to AVENZA_API_KEY env var.
33
+ model : LLM model identifier — used for cost table lookup.
34
+ owner : Email of the accountable human. Defaults to API key's owning user.
35
+ auto_instrument : Patch official LLM client libraries automatically (default True).
36
+ offline_buffer : Queue failed sends to local disk and retry on next init (default True).
37
+ base_url : Override for self-hosted deployments.
38
+ language : Runtime language tag sent in registration heartbeat.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ name: str,
45
+ risk_tier: str = "T1",
46
+ api_key: Optional[str] = None,
47
+ model: Optional[str] = None,
48
+ owner: Optional[str] = None,
49
+ auto_instrument: bool = True,
50
+ offline_buffer: bool = True,
51
+ base_url: Optional[str] = None,
52
+ language: str = "python",
53
+ ) -> None:
54
+ resolved_key = api_key or os.environ.get("AVENZA_API_KEY")
55
+ if not resolved_key:
56
+ raise AvenzaConfigError(
57
+ "No API key provided. Set AVENZA_API_KEY in your environment "
58
+ "or pass api_key= to Agent()."
59
+ )
60
+
61
+ self.name = name
62
+ self.risk_tier = risk_tier
63
+ self._model = model
64
+
65
+ import importlib.metadata as _meta
66
+ try:
67
+ sdk_version = _meta.version("avenza")
68
+ except Exception:
69
+ sdk_version = "dev"
70
+
71
+ self._client = AvenzaClient(
72
+ api_key=resolved_key,
73
+ base_url=base_url or os.environ.get("AVENZA_URL"),
74
+ offline_buffer=offline_buffer,
75
+ )
76
+
77
+ self._ref_getter = AgentRefGetter()
78
+
79
+ # Kick off agent registration on the background thread
80
+ self._client.post_async(
81
+ "/agents/register",
82
+ {
83
+ "agent_ref": None, # server assigns; name is the match key
84
+ "name": name,
85
+ "risk_tier": risk_tier,
86
+ "sdk_version": sdk_version,
87
+ "language": language,
88
+ **({"model": model} if model else {}),
89
+ **({"owner": owner} if owner else {}),
90
+ },
91
+ )
92
+ # Registration response will never come back through post_async.
93
+ # We do a synchronous registration call to get the agent_ref.
94
+ # This is the one-time network cost per Agent() instantiation.
95
+ self._register_sync(name, risk_tier, model, owner, sdk_version, language)
96
+
97
+ if auto_instrument:
98
+ from ._instrument import patch_all
99
+ patch_all()
100
+
101
+ def run(self, run_ref: Optional[str] = None) -> RunContext:
102
+ """Return a RunContext to wrap a single agent execution."""
103
+ return RunContext(
104
+ client=self._client,
105
+ agent_ref_getter=self._ref_getter,
106
+ run_ref=run_ref,
107
+ )
108
+
109
+ def task(
110
+ self,
111
+ *,
112
+ value_type: Optional[str] = None,
113
+ unit_value_usd: float = 0.0,
114
+ quantity: float = 1.0,
115
+ ) -> Any:
116
+ """Decorator that wraps a function in a run automatically."""
117
+ import functools
118
+
119
+ def decorator(fn: Any) -> Any:
120
+ @functools.wraps(fn)
121
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
122
+ with self.run() as run:
123
+ try:
124
+ result = fn(*args, **kwargs)
125
+ run.success = bool(result)
126
+ if value_type:
127
+ run.log_value(value_type, quantity=quantity, unit_value_usd=unit_value_usd)
128
+ return result
129
+ except Exception:
130
+ run.success = False
131
+ raise
132
+
133
+ @functools.wraps(fn)
134
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
135
+ async with self.run() as run:
136
+ try:
137
+ result = await fn(*args, **kwargs)
138
+ run.success = bool(result)
139
+ if value_type:
140
+ run.log_value(value_type, quantity=quantity, unit_value_usd=unit_value_usd)
141
+ return result
142
+ except Exception:
143
+ run.success = False
144
+ raise
145
+
146
+ import asyncio as _asyncio
147
+ if _asyncio.iscoroutinefunction(fn):
148
+ return async_wrapper
149
+ return sync_wrapper
150
+
151
+ return decorator
152
+
153
+ def _register_sync(
154
+ self,
155
+ name: str,
156
+ risk_tier: str,
157
+ model: Optional[str],
158
+ owner: Optional[str],
159
+ sdk_version: str,
160
+ language: str,
161
+ ) -> None:
162
+ try:
163
+ data = self._client.post_sync(
164
+ "/agents/register",
165
+ {
166
+ "agent_ref": name, # used as a hint; server may assign differently
167
+ "name": name,
168
+ "risk_tier": risk_tier,
169
+ "sdk_version": sdk_version,
170
+ "language": language,
171
+ **({"model": model} if model else {}),
172
+ **({"owner": owner} if owner else {}),
173
+ },
174
+ )
175
+ agent_ref: str = data.get("agent_ref", name)
176
+ self._ref_getter.set(agent_ref)
177
+ logger.debug("avenza: registered agent=%s ref=%s", name, agent_ref)
178
+ except Exception as exc:
179
+ logger.debug("avenza: registration failed, will retry — %s", exc)
180
+ self._ref_getter.set(name.upper().replace(" ", "-")[:20])