agentvend-sdk 0.0.1__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.
@@ -0,0 +1,75 @@
1
+ from .agentvend_headers import AgentVendHeaders
2
+ from .hmac_utils import calculate_hmac, calculate_hmac_with_timestamp, constant_time_equals, validate_hmac_signature
3
+ from .verifier import (
4
+ verify_signature,
5
+ verify_inbound_hmac,
6
+ verify_signature_from_headers,
7
+ verify_inbound_context,
8
+ verify_signature_from_headers_and_get_user_context,
9
+ get_user_context,
10
+ build_gateway_user_context_string,
11
+ UserContext,
12
+ SignedUserContext,
13
+ InboundHmacRequest,
14
+ )
15
+ from .validation_client import validate_agent_key, AgentKeyValidationResult
16
+ from .completion_status import CompletionStatus
17
+ from .usage_client import (
18
+ DEFAULT_USAGE_PATH_PREFIX,
19
+ report_completion,
20
+ report_completion_full,
21
+ report_completion_with_result,
22
+ report_progress,
23
+ report_usage,
24
+ report_usage_at,
25
+ UsageReportResponse,
26
+ )
27
+ from .gateway_client import get_request_status, get_request_result, GatewayPollResult
28
+ from .client import (
29
+ AgentVendClient,
30
+ DEFAULT_API_URL,
31
+ DEFAULT_CORE_PATH_PREFIX,
32
+ DEFAULT_GATEWAY_PATH_PREFIX,
33
+ ENV_AGENT_ID,
34
+ ENV_AGENT_SECRET,
35
+ ENV_API_URL,
36
+ )
37
+
38
+ __all__ = [
39
+ "AgentVendHeaders",
40
+ "calculate_hmac",
41
+ "calculate_hmac_with_timestamp",
42
+ "constant_time_equals",
43
+ "validate_hmac_signature",
44
+ "verify_signature",
45
+ "verify_inbound_hmac",
46
+ "verify_signature_from_headers",
47
+ "verify_inbound_context",
48
+ "verify_signature_from_headers_and_get_user_context",
49
+ "get_user_context",
50
+ "build_gateway_user_context_string",
51
+ "UserContext",
52
+ "SignedUserContext",
53
+ "InboundHmacRequest",
54
+ "validate_agent_key",
55
+ "AgentKeyValidationResult",
56
+ "CompletionStatus",
57
+ "report_progress",
58
+ "report_completion",
59
+ "report_completion_with_result",
60
+ "report_completion_full",
61
+ "report_usage",
62
+ "report_usage_at",
63
+ "DEFAULT_USAGE_PATH_PREFIX",
64
+ "UsageReportResponse",
65
+ "get_request_status",
66
+ "get_request_result",
67
+ "GatewayPollResult",
68
+ "AgentVendClient",
69
+ "DEFAULT_API_URL",
70
+ "ENV_API_URL",
71
+ "ENV_AGENT_ID",
72
+ "ENV_AGENT_SECRET",
73
+ "DEFAULT_CORE_PATH_PREFIX",
74
+ "DEFAULT_GATEWAY_PATH_PREFIX",
75
+ ]
@@ -0,0 +1,14 @@
1
+ """Canonical AgentVend HTTP header names."""
2
+
3
+
4
+ class AgentVendHeaders:
5
+ SIGNATURE = "X-AgentVend-Signature"
6
+ TIMESTAMP = "X-AgentVend-Timestamp"
7
+ USER_ID = "X-AgentVend-User-ID"
8
+ PLAN = "X-AgentVend-Plan"
9
+ ROLES = "X-AgentVend-Roles"
10
+ QUOTA_REMAINING = "X-AgentVend-Quota-Remaining"
11
+ SUBSCRIPTION_ACTIVE = "X-AgentVend-Subscription-Active"
12
+ BILLING_MODEL = "X-AgentVend-Billing-Model"
13
+ MEASUREMENT_TYPE = "X-AgentVend-Measurement-Type"
14
+ UNIT_LABEL = "X-AgentVend-Unit-Label"
@@ -0,0 +1,266 @@
1
+ """Unified HTTP client: env-based config and URL layout aligned with Java `AgentVendClient`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING, Optional
7
+
8
+ from .completion_status import CompletionStatus
9
+ from .gateway_client import (
10
+ DEFAULT_GATEWAY_PATH_PREFIX,
11
+ GatewayPollResult,
12
+ get_request_result,
13
+ get_request_status,
14
+ )
15
+ from .usage_client import (
16
+ DEFAULT_USAGE_PATH_PREFIX,
17
+ report_completion,
18
+ report_completion_full,
19
+ report_progress,
20
+ report_usage,
21
+ report_usage_at,
22
+ UsageReportResponse,
23
+ )
24
+ from .validation_client import (
25
+ DEFAULT_CORE_PATH_PREFIX,
26
+ AgentKeyValidationResult,
27
+ validate_agent_key,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ import requests
32
+
33
+ ENV_API_URL = "AGENTVEND_API_URL"
34
+ ENV_AGENT_ID = "AGENTVEND_AGENT_ID"
35
+ ENV_AGENT_SECRET = "AGENTVEND_AGENT_SECRET"
36
+
37
+ # Production API origin; override with `api_url=...` or `AGENTVEND_API_URL` for tests/staging.
38
+ DEFAULT_API_URL = "https://api.agentvend.api"
39
+
40
+
41
+ def _trim_trailing_slashes(s: str) -> str:
42
+ t = s.strip()
43
+ while t.endswith("/"):
44
+ t = t[:-1]
45
+ return t
46
+
47
+
48
+ def _join_url(base: str, path: Optional[str]) -> str:
49
+ b = _trim_trailing_slashes(base)
50
+ if not path:
51
+ return b
52
+ p = path if path.startswith("/") else f"/{path}"
53
+ return b + p
54
+
55
+
56
+ def _first_non_blank(a: Optional[str], b: Optional[str]) -> str:
57
+ if a is not None and a.strip():
58
+ return a.strip()
59
+ if b is not None and b.strip():
60
+ return b.strip()
61
+ return ""
62
+
63
+
64
+ def _resolve_api_url(api_url: Optional[str]) -> str:
65
+ if api_url is not None and api_url.strip():
66
+ return _trim_trailing_slashes(api_url.strip())
67
+ env = os.environ.get(ENV_API_URL)
68
+ if env is not None and env.strip():
69
+ return _trim_trailing_slashes(env.strip())
70
+ return DEFAULT_API_URL
71
+
72
+
73
+ class AgentVendClient:
74
+ """
75
+ Single entry point for Core validate, Usage report/progress/complete, and Gateway polling.
76
+ Explicit constructor arguments override environment variables (same order as Java `Builder`).
77
+ The API origin defaults to :data:`DEFAULT_API_URL` when neither `api_url` nor ``AGENTVEND_API_URL`` is set.
78
+ """
79
+
80
+ ENV_API_URL = ENV_API_URL
81
+ ENV_AGENT_ID = ENV_AGENT_ID
82
+ ENV_AGENT_SECRET = ENV_AGENT_SECRET
83
+ DEFAULT_API_URL = DEFAULT_API_URL
84
+ DEFAULT_CORE_PATH_PREFIX = DEFAULT_CORE_PATH_PREFIX
85
+ DEFAULT_GATEWAY_PATH_PREFIX = DEFAULT_GATEWAY_PATH_PREFIX
86
+ DEFAULT_USAGE_PATH_PREFIX = DEFAULT_USAGE_PATH_PREFIX
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ api_url: Optional[str] = None,
92
+ core_api_url: Optional[str] = None,
93
+ gateway_api_url: Optional[str] = None,
94
+ usage_api_url: Optional[str] = None,
95
+ core_path_prefix: Optional[str] = None,
96
+ gateway_path_prefix: Optional[str] = None,
97
+ usage_path_prefix: Optional[str] = None,
98
+ agent_id: Optional[str] = None,
99
+ agent_secret: Optional[str] = None,
100
+ session: Optional["requests.Session"] = None,
101
+ ) -> None:
102
+ resolved = _resolve_api_url(api_url)
103
+
104
+ core_base = _trim_trailing_slashes(_first_non_blank(core_api_url, resolved))
105
+ gw_base = _trim_trailing_slashes(_first_non_blank(gateway_api_url, resolved))
106
+ usage_base = _trim_trailing_slashes(_first_non_blank(usage_api_url, resolved))
107
+
108
+ cp = core_path_prefix if core_path_prefix is not None else DEFAULT_CORE_PATH_PREFIX
109
+ gp = gateway_path_prefix if gateway_path_prefix is not None else DEFAULT_GATEWAY_PATH_PREFIX
110
+ up = usage_path_prefix if usage_path_prefix is not None else DEFAULT_USAGE_PATH_PREFIX
111
+
112
+ sec = _first_non_blank(agent_secret, os.environ.get(ENV_AGENT_SECRET))
113
+ if not sec:
114
+ raise ValueError(
115
+ f"Agent secret is required: pass agent_secret=... or set environment variable {ENV_AGENT_SECRET}"
116
+ )
117
+
118
+ aid = _first_non_blank(agent_id, os.environ.get(ENV_AGENT_ID))
119
+ aid_opt: Optional[str] = aid if aid else None
120
+
121
+ self._gateway_base_url = gw_base
122
+ self._gateway_path_prefix = gp
123
+ self._core_base = core_base
124
+ self._core_path_prefix = cp
125
+ self._usage_base = usage_base
126
+ self._usage_path_prefix = up
127
+ self._agent_id = aid_opt
128
+ self._agent_secret = sec
129
+ self._session = session
130
+
131
+ @classmethod
132
+ def from_env(cls, *, session: Optional["requests.Session"] = None) -> AgentVendClient:
133
+ """Build from environment (optional `AGENTVEND_API_URL` / agent id / secret). Uses :data:`DEFAULT_API_URL` when unset."""
134
+ return cls(session=session)
135
+
136
+ def validate_agent_key(self, agent_key: str) -> Optional[AgentKeyValidationResult]:
137
+ return validate_agent_key(
138
+ self._core_base,
139
+ agent_key,
140
+ self._agent_secret,
141
+ self._agent_id,
142
+ core_path_prefix=self._core_path_prefix,
143
+ session=self._session,
144
+ )
145
+
146
+ def report_usage(
147
+ self,
148
+ user_id: str,
149
+ agent_id: str,
150
+ units_used: float,
151
+ *,
152
+ session: Optional["requests.Session"] = None,
153
+ ) -> UsageReportResponse:
154
+ return report_usage(
155
+ self._usage_base,
156
+ user_id,
157
+ agent_id,
158
+ units_used,
159
+ self._agent_secret,
160
+ usage_path_prefix=self._usage_path_prefix,
161
+ session=session or self._session,
162
+ )
163
+
164
+ def report_usage_at(
165
+ self,
166
+ user_id: str,
167
+ agent_id: str,
168
+ units_used: float,
169
+ timestamp: Optional[float],
170
+ *,
171
+ session: Optional["requests.Session"] = None,
172
+ ) -> UsageReportResponse:
173
+ return report_usage_at(
174
+ self._usage_base,
175
+ user_id,
176
+ agent_id,
177
+ units_used,
178
+ self._agent_secret,
179
+ timestamp=timestamp,
180
+ usage_path_prefix=self._usage_path_prefix,
181
+ session=session or self._session,
182
+ )
183
+
184
+ def send_progress_update(
185
+ self,
186
+ progress_url: str,
187
+ request_id: str,
188
+ stage: str,
189
+ percentage_complete: int,
190
+ error_message: Optional[str] = None,
191
+ *,
192
+ session: Optional["requests.Session"] = None,
193
+ ) -> bool:
194
+ return report_progress(
195
+ progress_url,
196
+ request_id,
197
+ stage,
198
+ percentage_complete,
199
+ self._agent_secret,
200
+ error_message,
201
+ session=session or self._session,
202
+ )
203
+
204
+ def send_completion(
205
+ self,
206
+ callback_url: str,
207
+ request_id: str,
208
+ status: CompletionStatus,
209
+ units: float,
210
+ *,
211
+ result: Optional[str] = None,
212
+ result_url: Optional[str] = None,
213
+ content_type: Optional[str] = None,
214
+ session: Optional["requests.Session"] = None,
215
+ ) -> bool:
216
+ sess = session or self._session
217
+ if result is not None or result_url is not None or content_type is not None:
218
+ return report_completion_full(
219
+ callback_url,
220
+ request_id,
221
+ status,
222
+ self._agent_secret,
223
+ result=result,
224
+ result_url=result_url,
225
+ content_type=content_type,
226
+ units=units,
227
+ session=sess,
228
+ )
229
+ return report_completion(
230
+ callback_url,
231
+ request_id,
232
+ status,
233
+ self._agent_secret,
234
+ units=units,
235
+ session=sess,
236
+ )
237
+
238
+ def get_request_status(
239
+ self,
240
+ request_id: str,
241
+ agent_key: str,
242
+ *,
243
+ session: Optional["requests.Session"] = None,
244
+ ) -> GatewayPollResult:
245
+ return get_request_status(
246
+ self._gateway_base_url,
247
+ request_id,
248
+ agent_key,
249
+ gateway_path_prefix=self._gateway_path_prefix,
250
+ session=session or self._session,
251
+ )
252
+
253
+ def get_request_result(
254
+ self,
255
+ request_id: str,
256
+ agent_key: str,
257
+ *,
258
+ session: Optional["requests.Session"] = None,
259
+ ) -> GatewayPollResult:
260
+ return get_request_result(
261
+ self._gateway_base_url,
262
+ request_id,
263
+ agent_key,
264
+ gateway_path_prefix=self._gateway_path_prefix,
265
+ session=session or self._session,
266
+ )
@@ -0,0 +1,12 @@
1
+ from enum import Enum
2
+
3
+
4
+ class CompletionStatus(str, Enum):
5
+ """Completion status for usage async completion (sdk-api-spec §3.3)."""
6
+
7
+ COMPLETED = "COMPLETED"
8
+ FAILED = "FAILED"
9
+
10
+ @property
11
+ def api_value(self) -> str:
12
+ return self.value
@@ -0,0 +1,71 @@
1
+ """Caller-side gateway polling (docs/sdk-api-spec.md §1.3–1.4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Optional
7
+
8
+ if TYPE_CHECKING:
9
+ import requests
10
+
11
+ DEFAULT_GATEWAY_PATH_PREFIX = "/api"
12
+
13
+
14
+ def _normalize_base(url: str) -> str:
15
+ return url.rstrip("/")
16
+
17
+
18
+ def _normalize_prefix(prefix: str) -> str:
19
+ if not prefix:
20
+ return ""
21
+ p = prefix if prefix.startswith("/") else f"/{prefix}"
22
+ return p.rstrip("/")
23
+
24
+
25
+ def _build_url(gateway_base_url: str, gateway_path_prefix: str, suffix: str) -> str:
26
+ return f"{_normalize_base(gateway_base_url)}{_normalize_prefix(gateway_path_prefix)}{suffix}"
27
+
28
+
29
+ @dataclass
30
+ class GatewayPollResult:
31
+ ok: bool
32
+ status_code: int
33
+ body: str
34
+
35
+
36
+ def get_request_status(
37
+ base_url: str,
38
+ request_id: str,
39
+ agent_key: str,
40
+ *,
41
+ gateway_path_prefix: str = DEFAULT_GATEWAY_PATH_PREFIX,
42
+ session: Optional["requests.Session"] = None,
43
+ ) -> GatewayPollResult:
44
+ """GET .../requests/{request_id}/status with Bearer agent key."""
45
+ try:
46
+ import requests
47
+ except ImportError as e:
48
+ raise ImportError("get_request_status requires 'requests'. pip install requests") from e
49
+ url = _build_url(base_url, gateway_path_prefix, f"/requests/{request_id}/status")
50
+ sess = session or requests.Session()
51
+ resp = sess.get(url, headers={"Authorization": f"Bearer {agent_key}"}, timeout=60)
52
+ return GatewayPollResult(ok=resp.ok, status_code=resp.status_code, body=resp.text or "")
53
+
54
+
55
+ def get_request_result(
56
+ base_url: str,
57
+ request_id: str,
58
+ agent_key: str,
59
+ *,
60
+ gateway_path_prefix: str = DEFAULT_GATEWAY_PATH_PREFIX,
61
+ session: Optional["requests.Session"] = None,
62
+ ) -> GatewayPollResult:
63
+ """GET .../requests/{request_id}/result with Bearer agent key."""
64
+ try:
65
+ import requests
66
+ except ImportError as e:
67
+ raise ImportError("get_request_result requires 'requests'. pip install requests") from e
68
+ url = _build_url(base_url, gateway_path_prefix, f"/requests/{request_id}/result")
69
+ sess = session or requests.Session()
70
+ resp = sess.get(url, headers={"Authorization": f"Bearer {agent_key}"}, timeout=60)
71
+ return GatewayPollResult(ok=resp.ok, status_code=resp.status_code, body=resp.text or "")
@@ -0,0 +1,34 @@
1
+ import base64
2
+ import hmac
3
+ import hashlib
4
+ from typing import Optional
5
+
6
+
7
+ def calculate_hmac(data: str, key: str) -> str:
8
+ """HMAC-SHA256, UTF-8, Base64 (per docs/hmac-spec.md)."""
9
+ digest = hmac.new(key.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).digest()
10
+ return base64.b64encode(digest).decode("ascii")
11
+
12
+
13
+ def calculate_hmac_with_timestamp(body_string: str, timestamp: str, key: str) -> str:
14
+ """Outbound: canonical = bodyString + timestamp."""
15
+ return calculate_hmac(body_string + timestamp, key)
16
+
17
+
18
+ def constant_time_equals(a: Optional[str], b: Optional[str]) -> bool:
19
+ """Constant-time comparison to avoid timing attacks."""
20
+ if a is None or b is None:
21
+ return a is b
22
+ if len(a) != len(b):
23
+ return False
24
+ return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
25
+
26
+
27
+ def validate_hmac_signature(signature: str, payload_string: str, key: str) -> bool:
28
+ if not signature or not key:
29
+ return False
30
+ try:
31
+ expected = calculate_hmac(payload_string, key)
32
+ return constant_time_equals(expected, signature)
33
+ except Exception:
34
+ return False
@@ -0,0 +1,212 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Dict, Optional
5
+ from urllib.parse import parse_qs, urlparse
6
+
7
+ from .agentvend_headers import AgentVendHeaders
8
+ from .completion_status import CompletionStatus
9
+ from .hmac_utils import calculate_hmac_with_timestamp
10
+
11
+ DEFAULT_USAGE_PATH_PREFIX = "/api/usage"
12
+
13
+
14
+ def _usage_report_url(base_url: str, usage_path_prefix: Optional[str]) -> str:
15
+ base = base_url.rstrip("/")
16
+ p = (usage_path_prefix or DEFAULT_USAGE_PATH_PREFIX).strip()
17
+ if not p.startswith("/"):
18
+ p = "/" + p
19
+ p = p.rstrip("/")
20
+ return f"{base}{p}/report"
21
+
22
+
23
+ @dataclass
24
+ class UsageReportResponse:
25
+ status: Optional[str]
26
+ is_over_limit: bool
27
+ remaining_requests_per_period: int
28
+
29
+
30
+ def _parse_url_params(url: str) -> tuple[str, Optional[str]]:
31
+ parsed = urlparse(url)
32
+ base = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" if parsed.scheme else url.split("?")[0]
33
+ if not parsed.query:
34
+ return base, None
35
+ qs = parse_qs(parsed.query)
36
+ timestamp = (qs.get("timestamp") or [None])[0]
37
+ return base, timestamp
38
+
39
+
40
+ def report_progress(
41
+ progress_url: str,
42
+ request_id: str,
43
+ stage: str,
44
+ percentage_complete: int,
45
+ agent_secret: str,
46
+ error_message: Optional[str] = None,
47
+ *,
48
+ session: Optional["requests.Session"] = None,
49
+ ) -> bool:
50
+ """POST progress to usage service. Requires 'requests'."""
51
+ try:
52
+ import requests
53
+ except ImportError:
54
+ raise ImportError("report_progress requires 'requests'. pip install requests")
55
+ base_url, timestamp = _parse_url_params(progress_url)
56
+ if not timestamp:
57
+ return False
58
+ body = {"stage": stage, "percentageComplete": percentage_complete, "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")}
59
+ if error_message is not None:
60
+ body["errorMessage"] = error_message
61
+ body_str = json.dumps(body)
62
+ signature = calculate_hmac_with_timestamp(body_str, timestamp, agent_secret)
63
+ sess = session or __import__("requests").Session()
64
+ resp = sess.post(
65
+ base_url,
66
+ json=body,
67
+ headers={AgentVendHeaders.SIGNATURE: signature, AgentVendHeaders.TIMESTAMP: timestamp},
68
+ )
69
+ return resp.ok
70
+
71
+
72
+ def report_completion(
73
+ callback_url: str,
74
+ request_id: str,
75
+ status: CompletionStatus,
76
+ agent_secret: str,
77
+ *,
78
+ units: float = 0.0,
79
+ session: Optional["requests.Session"] = None,
80
+ ) -> bool:
81
+ """POST completion with status and units only."""
82
+ return report_completion_full(
83
+ callback_url,
84
+ request_id,
85
+ status,
86
+ agent_secret,
87
+ units=units,
88
+ session=session,
89
+ )
90
+
91
+
92
+ def report_completion_with_result(
93
+ callback_url: str,
94
+ request_id: str,
95
+ status: CompletionStatus,
96
+ agent_secret: str,
97
+ result: str,
98
+ *,
99
+ units: float = 0.0,
100
+ session: Optional["requests.Session"] = None,
101
+ ) -> bool:
102
+ """POST completion with inline result text."""
103
+ return report_completion_full(
104
+ callback_url,
105
+ request_id,
106
+ status,
107
+ agent_secret,
108
+ result=result,
109
+ units=units,
110
+ session=session,
111
+ )
112
+
113
+
114
+ def report_completion_full(
115
+ callback_url: str,
116
+ request_id: str,
117
+ status: CompletionStatus,
118
+ agent_secret: str,
119
+ *,
120
+ result: Optional[str] = None,
121
+ result_url: Optional[str] = None,
122
+ content_type: Optional[str] = None,
123
+ units: float = 0.0,
124
+ session: Optional["requests.Session"] = None,
125
+ ) -> bool:
126
+ try:
127
+ import requests
128
+ except ImportError:
129
+ raise ImportError("report_completion_full requires 'requests'. pip install requests")
130
+ base_url, timestamp = _parse_url_params(callback_url)
131
+ if not timestamp:
132
+ return False
133
+ body = {
134
+ "status": status.api_value,
135
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
136
+ "units": units,
137
+ }
138
+ if result is not None:
139
+ body["result"] = result
140
+ if result_url is not None:
141
+ body["resultUrl"] = result_url
142
+ if content_type is not None:
143
+ body["contentType"] = content_type
144
+ body_str = json.dumps(body)
145
+ signature = calculate_hmac_with_timestamp(body_str, timestamp, agent_secret)
146
+ sess = session or requests.Session()
147
+ resp = sess.post(
148
+ base_url,
149
+ json=body,
150
+ headers={AgentVendHeaders.SIGNATURE: signature, AgentVendHeaders.TIMESTAMP: timestamp},
151
+ )
152
+ return resp.ok
153
+
154
+
155
+ def report_usage(
156
+ base_url: str,
157
+ user_id: str,
158
+ agent_id: str,
159
+ units_used: float,
160
+ agent_secret: str,
161
+ *,
162
+ usage_path_prefix: Optional[str] = None,
163
+ session: Optional["requests.Session"] = None,
164
+ ) -> UsageReportResponse:
165
+ """Report usage with current time as timestamp."""
166
+ return report_usage_at(
167
+ base_url,
168
+ user_id,
169
+ agent_id,
170
+ units_used,
171
+ agent_secret,
172
+ timestamp=None,
173
+ usage_path_prefix=usage_path_prefix,
174
+ session=session,
175
+ )
176
+
177
+
178
+ def report_usage_at(
179
+ base_url: str,
180
+ user_id: str,
181
+ agent_id: str,
182
+ units_used: float,
183
+ agent_secret: str,
184
+ timestamp: Optional[float] = None,
185
+ *,
186
+ usage_path_prefix: Optional[str] = None,
187
+ session: Optional["requests.Session"] = None,
188
+ ) -> UsageReportResponse:
189
+ try:
190
+ import requests
191
+ import time
192
+ except ImportError:
193
+ raise ImportError("report_usage requires 'requests'. pip install requests")
194
+ ts_ms = int((timestamp or time.time()) * 1000)
195
+ body = {"userId": user_id, "agentId": agent_id, "unitsUsed": units_used, "timestamp": ts_ms}
196
+ body_str = json.dumps(body)
197
+ ts_str = str(ts_ms)
198
+ signature = calculate_hmac_with_timestamp(body_str, ts_str, agent_secret)
199
+ url = _usage_report_url(base_url, usage_path_prefix)
200
+ sess = session or requests.Session()
201
+ resp = sess.post(
202
+ url,
203
+ json=body,
204
+ headers={AgentVendHeaders.SIGNATURE: signature, AgentVendHeaders.TIMESTAMP: ts_str},
205
+ )
206
+ resp.raise_for_status()
207
+ data = resp.json()
208
+ return UsageReportResponse(
209
+ status=data.get("status"),
210
+ is_over_limit=bool(data.get("isOverLimit")),
211
+ remaining_requests_per_period=int(data.get("remainingRequestsPerPeriod", 0)),
212
+ )