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.
- agentvend_sdk/__init__.py +75 -0
- agentvend_sdk/agentvend_headers.py +14 -0
- agentvend_sdk/client.py +266 -0
- agentvend_sdk/completion_status.py +12 -0
- agentvend_sdk/gateway_client.py +71 -0
- agentvend_sdk/hmac_utils.py +34 -0
- agentvend_sdk/usage_client.py +212 -0
- agentvend_sdk/validation_client.py +73 -0
- agentvend_sdk/verifier.py +236 -0
- agentvend_sdk-0.0.1.dist-info/METADATA +234 -0
- agentvend_sdk-0.0.1.dist-info/RECORD +14 -0
- agentvend_sdk-0.0.1.dist-info/WHEEL +5 -0
- agentvend_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
- agentvend_sdk-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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"
|
agentvend_sdk/client.py
ADDED
|
@@ -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,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
|
+
)
|