answerline 0.1.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.
- answerline/__init__.py +548 -0
- answerline/py.typed +0 -0
- answerline/webhooks.py +28 -0
- answerline-0.1.0.dist-info/METADATA +57 -0
- answerline-0.1.0.dist-info/RECORD +8 -0
- answerline-0.1.0.dist-info/WHEEL +5 -0
- answerline-0.1.0.dist-info/licenses/LICENSE +21 -0
- answerline-0.1.0.dist-info/top_level.txt +1 -0
answerline/__init__.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Client for the answer and search monitoring API, on httpx.
|
|
2
|
+
|
|
3
|
+
`Client` blocks and `AsyncClient` runs on asyncio; both have the same methods (awaitable on `AsyncClient`) and one
|
|
4
|
+
implementation. Each holds one httpx client: keep-alive connections, HTTPS, proxies from the environment. A `Client`
|
|
5
|
+
can be shared across threads, an `AsyncClient` across tasks of one event loop. Close it with `close()` / `aclose()` or
|
|
6
|
+
a `with` / `async with` block. Pass `http_client` (an `httpx.Client` or `httpx.AsyncClient`) to set TLS trust, proxies
|
|
7
|
+
or connection limits; closing the SDK client closes it.
|
|
8
|
+
|
|
9
|
+
Retries (`max_retries`, default 2, capped exponential backoff with full jitter):
|
|
10
|
+
- 429 is always retried: the request was not admitted, so nothing ran or was charged. `Retry-After` is honoured when
|
|
11
|
+
the server sends one (up to 8 s; a longer one raises).
|
|
12
|
+
- 5xx, timeouts and dropped connections are retried only for requests that are safe to repeat: GET, and async task
|
|
13
|
+
creation — a task without an `idempotencyKey` is given a generated one, and each creation call sends an
|
|
14
|
+
`Idempotency-Key` header of its own, so a repeat after a lost answer returns what the first attempt created. (Without
|
|
15
|
+
that header a repeat of a created task would answer 409 `RESOURCE_CONFLICT`, a batch item
|
|
16
|
+
`RESOURCE_ALREADY_EXISTS`): the task exists.
|
|
17
|
+
- Sync monitor calls are charged on success and may already be running when a connection drops or a read times out,
|
|
18
|
+
so they are retried after a transport error only when no connection was made (nothing sent). A 500 or 502 from them
|
|
19
|
+
is a final failure the server has already retried. Clearing the queue is treated the same way: the server finishes a
|
|
20
|
+
clear it has started, and a repeat would also delete tasks queued since.
|
|
21
|
+
- Other 4xx are never retried.
|
|
22
|
+
|
|
23
|
+
Timeouts are httpx timeouts per attempt: connecting, each read and write, and waiting for a pooled connection each get
|
|
24
|
+
the whole value. Transport failures raise TimeoutError or ConnectionError, chained to the httpx error.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import copy
|
|
31
|
+
import email.utils
|
|
32
|
+
import json
|
|
33
|
+
import random
|
|
34
|
+
import time
|
|
35
|
+
import urllib.parse
|
|
36
|
+
import uuid
|
|
37
|
+
from datetime import timezone
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from typing import Any, Dict, Generator, List, Optional, Tuple, TypeVar, Union
|
|
40
|
+
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
from .webhooks import SIGNATURE_HEADER, verify_webhook
|
|
44
|
+
|
|
45
|
+
__all__ = ["MAX_BATCH", "ApiError", "AsyncClient", "Client", "Meta", "SIGNATURE_HEADER", "verify_webhook"]
|
|
46
|
+
|
|
47
|
+
# Sent as `answerline-py/<version>`; keep in step with pyproject.toml.
|
|
48
|
+
__version__ = "0.1.0"
|
|
49
|
+
MAX_BATCH = 500
|
|
50
|
+
DEFAULT_BASE_URL = "https://api.answerline.dev"
|
|
51
|
+
TIMEOUT_S = 30.0
|
|
52
|
+
# Sync monitor calls and task creation: the server answers a sync call within its 330 s deadline plus one result read
|
|
53
|
+
# (6.5 s), and keeps resending a creation through a failover until 330 s, so the client never gives up just as the
|
|
54
|
+
# server answers.
|
|
55
|
+
SYNC_TIMEOUT_S = 360.0
|
|
56
|
+
# The server resends a clear that did not reach its dispatcher for up to 120 s, then allows the clear 120 s.
|
|
57
|
+
CLEAR_QUEUE_TIMEOUT_S = 270.0
|
|
58
|
+
# A task's longest run once started (5 minutes) and a wait for a slot, plus margin.
|
|
59
|
+
WAIT_TIMEOUT_S = 660.0
|
|
60
|
+
MAX_RETRIES = 2
|
|
61
|
+
_BACKOFF_S = 0.5
|
|
62
|
+
_MAX_DELAY_S = 8.0
|
|
63
|
+
# Not DELETE: repeating DELETE /v1/async/queue deletes tasks queued since the first attempt.
|
|
64
|
+
_IDEMPOTENT = frozenset({"GET", "HEAD", "PUT", "OPTIONS"})
|
|
65
|
+
# No connection was made, so the request never left.
|
|
66
|
+
_NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
|
|
67
|
+
# The request may have reached the server.
|
|
68
|
+
_TRANSIENT = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)
|
|
69
|
+
_TERMINAL = frozenset({"COMPLETED", "FAILED"})
|
|
70
|
+
_LIMIT_HEADERS = {
|
|
71
|
+
"rate_limit_limit": "X-RateLimit-Limit",
|
|
72
|
+
"rate_limit_remaining": "X-RateLimit-Remaining",
|
|
73
|
+
"concurrent_limit": "X-Concurrent-Limit",
|
|
74
|
+
"concurrent_current": "X-Concurrent-Current",
|
|
75
|
+
"concurrent_remaining": "X-Concurrent-Remaining",
|
|
76
|
+
"credits_remaining": "X-Credits-Remaining",
|
|
77
|
+
"credits_charged": "X-Credits-Charged",
|
|
78
|
+
"latency_ms": "X-Latency-Ms",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
T = TypeVar("T")
|
|
82
|
+
# A call is a generator of steps: it yields a request to send (and is sent back the response, or the exception sending
|
|
83
|
+
# raised) or a delay in seconds to wait. `Client` and `AsyncClient` differ only in how they run the steps.
|
|
84
|
+
Steps = Generator[Union[httpx.Request, float], Any, T]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Meta:
|
|
89
|
+
"""A response's status, headers, its X-Request-Id (quote it to support), and the limit and cost headers it carried
|
|
90
|
+
(absent or malformed ones are None)."""
|
|
91
|
+
|
|
92
|
+
status: int
|
|
93
|
+
headers: httpx.Headers
|
|
94
|
+
request_id: Optional[str] = None
|
|
95
|
+
rate_limit_limit: Optional[int] = None
|
|
96
|
+
rate_limit_remaining: Optional[int] = None
|
|
97
|
+
concurrent_limit: Optional[int] = None
|
|
98
|
+
concurrent_current: Optional[int] = None
|
|
99
|
+
concurrent_remaining: Optional[int] = None
|
|
100
|
+
credits_remaining: Optional[int] = None
|
|
101
|
+
credits_charged: Optional[int] = None
|
|
102
|
+
latency_ms: Optional[int] = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ApiError(Exception):
|
|
106
|
+
"""Non-2xx response, or a body that is not JSON. `code` is the stable machine-readable error code when present;
|
|
107
|
+
`body` is the parsed JSON or the raw text."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, status: int, code: Optional[str], body: Any, meta: Meta):
|
|
110
|
+
request = f" (request {meta.request_id})" if meta.request_id else ""
|
|
111
|
+
super().__init__((f"{status} {code}" if code else f"HTTP {status}") + request)
|
|
112
|
+
self.status = status
|
|
113
|
+
self.code = code
|
|
114
|
+
self.body = body
|
|
115
|
+
self.meta = meta
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _Api:
|
|
119
|
+
"""Methods shared by both clients. Each returns `_run(steps)`: the result, or an awaitable of it."""
|
|
120
|
+
|
|
121
|
+
_http_class: Any
|
|
122
|
+
|
|
123
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = TIMEOUT_S,
|
|
124
|
+
sync_timeout: float = SYNC_TIMEOUT_S, max_retries: int = MAX_RETRIES, http_client: Any = None):
|
|
125
|
+
"""`timeout` applies to every call except sync monitor calls and task creation, which get `sync_timeout`, and
|
|
126
|
+
clearing the queue, which gets `CLEAR_QUEUE_TIMEOUT_S`."""
|
|
127
|
+
self.api_key = api_key
|
|
128
|
+
self.base_url = base_url.rstrip("/")
|
|
129
|
+
self.timeout = timeout
|
|
130
|
+
self.sync_timeout = sync_timeout
|
|
131
|
+
self.max_retries = max_retries
|
|
132
|
+
self._http = http_client if http_client is not None else self._http_class()
|
|
133
|
+
self._owns_http = True
|
|
134
|
+
|
|
135
|
+
def _run(self, steps: Steps[T]) -> Any:
|
|
136
|
+
raise NotImplementedError
|
|
137
|
+
|
|
138
|
+
def with_options(self, timeout: Optional[float] = None, sync_timeout: Optional[float] = None,
|
|
139
|
+
max_retries: Optional[int] = None) -> Any:
|
|
140
|
+
"""A client sharing this one's connections, with the given options replaced. A clone does not own the
|
|
141
|
+
pool: `close()` on it is a no-op — close the client it was made from."""
|
|
142
|
+
clone = copy.copy(self)
|
|
143
|
+
changes = {"timeout": timeout, "sync_timeout": sync_timeout, "max_retries": max_retries}
|
|
144
|
+
clone.__dict__.update((name, value) for name, value in changes.items() if value is not None)
|
|
145
|
+
clone.__dict__["_owns_http"] = False
|
|
146
|
+
return clone
|
|
147
|
+
|
|
148
|
+
def request(self, method: str, path: str, body: Any = None, headers: Optional[Dict[str, str]] = None) -> Any:
|
|
149
|
+
"""The parsed body and response metadata, with the retry policy above. Raises ApiError on non-2xx or a non-JSON
|
|
150
|
+
body, TimeoutError when an attempt times out, and ConnectionError on another transport failure.
|
|
151
|
+
|
|
152
|
+
`headers` are sent with the request, such as your own `X-Request-Id`; the SDK's Authorization, Accept,
|
|
153
|
+
User-Agent and Content-Type take precedence."""
|
|
154
|
+
return self._run(self._request(method, path, body, headers=headers))
|
|
155
|
+
|
|
156
|
+
def _request(self, method: str, path: str, body: Any, timeout: Optional[float] = None,
|
|
157
|
+
headers: Optional[Dict[str, str]] = None) -> Steps[Tuple[Any, Meta]]:
|
|
158
|
+
headers = httpx.Headers(headers or {})
|
|
159
|
+
headers.update({"Authorization": f"Bearer {self.api_key}", "Accept": "application/json",
|
|
160
|
+
"User-Agent": f"answerline-py/{__version__}"})
|
|
161
|
+
if body is not None:
|
|
162
|
+
headers["Content-Type"] = "application/json"
|
|
163
|
+
if timeout is None:
|
|
164
|
+
long = path.startswith("/v1/monitor/") or (method.upper() == "POST" and path.startswith("/v1/async/task"))
|
|
165
|
+
timeout = self.sync_timeout if long else self.timeout
|
|
166
|
+
# NaN/Infinity would serialize as bare tokens the server cannot parse: fail before sending.
|
|
167
|
+
request = self._http.build_request(method, self.base_url + path, headers=headers, timeout=httpx.Timeout(timeout),
|
|
168
|
+
content=None if body is None else json.dumps(body, allow_nan=False).encode())
|
|
169
|
+
replayable = _replayable(method, body)
|
|
170
|
+
attempt = 0
|
|
171
|
+
while True:
|
|
172
|
+
try:
|
|
173
|
+
return _parse((yield request))
|
|
174
|
+
except (ApiError, httpx.TransportError) as err:
|
|
175
|
+
delay = _retry_delay(err, replayable, attempt, _MAX_DELAY_S) if attempt < self.max_retries else None
|
|
176
|
+
if delay is None:
|
|
177
|
+
raise
|
|
178
|
+
yield delay
|
|
179
|
+
attempt += 1
|
|
180
|
+
|
|
181
|
+
def _data(self, method: str, path: str, body: Any = None, timeout: Optional[float] = None,
|
|
182
|
+
headers: Optional[Dict[str, str]] = None) -> Steps[Any]:
|
|
183
|
+
return (yield from self._request(method, path, body, timeout, headers))[0]
|
|
184
|
+
|
|
185
|
+
def _create(self, path: str, body: Any) -> Steps[Any]:
|
|
186
|
+
"""Task creation under an `Idempotency-Key` naming this call: every retry sends it, so the API answers a repeat
|
|
187
|
+
with what the first attempt created."""
|
|
188
|
+
return (yield from self._data("POST", path, body, headers={"Idempotency-Key": str(uuid.uuid4())}))
|
|
189
|
+
|
|
190
|
+
# -- sync monitor calls -------------------------------------------------
|
|
191
|
+
@property
|
|
192
|
+
def monitor(self) -> "_Monitor":
|
|
193
|
+
"""Namespaced monitor calls: `client.monitor.chatgpt(prompt=..., country=...)`; also callable as
|
|
194
|
+
`monitor(engine, **body)`."""
|
|
195
|
+
return _Monitor(self)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def async_tasks(self) -> "_AsyncTasks":
|
|
199
|
+
"""Namespaced async-task calls: `client.async_tasks.create_batch(tasks)`, `.run(...)`, `.wait(id)`."""
|
|
200
|
+
return _AsyncTasks(self)
|
|
201
|
+
|
|
202
|
+
def chatgpt(self, prompt: str, country: str, **options: Any) -> Any:
|
|
203
|
+
return self.monitor("chatgpt", prompt=prompt, country=country, **options)
|
|
204
|
+
|
|
205
|
+
def perplexity(self, prompt: str, country: str, **options: Any) -> Any:
|
|
206
|
+
return self.monitor("perplexity", prompt=prompt, country=country, **options)
|
|
207
|
+
|
|
208
|
+
def gemini(self, prompt: str, country: str, **options: Any) -> Any:
|
|
209
|
+
return self.monitor("gemini", prompt=prompt, country=country, **options)
|
|
210
|
+
|
|
211
|
+
def copilot(self, prompt: str, country: str, **options: Any) -> Any:
|
|
212
|
+
return self.monitor("copilot", prompt=prompt, country=country, **options)
|
|
213
|
+
|
|
214
|
+
def grok(self, prompt: str, country: str, **options: Any) -> Any:
|
|
215
|
+
return self.monitor("grok", prompt=prompt, country=country, **options)
|
|
216
|
+
|
|
217
|
+
def ai_mode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None, **options: Any) -> Any:
|
|
218
|
+
return self.monitor("aimode", **_geo({"prompt": prompt}, country, gl, options))
|
|
219
|
+
|
|
220
|
+
def google(self, **options: Any) -> Any:
|
|
221
|
+
return self.monitor("google", **options)
|
|
222
|
+
|
|
223
|
+
def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None, **options: Any) -> Any:
|
|
224
|
+
return self.monitor("google/news", **_geo({"query": query}, country, gl, options))
|
|
225
|
+
|
|
226
|
+
# -- async tasks ----------------------------------------------------------
|
|
227
|
+
def create_task(self, task_type: str, payload: Dict[str, Any], priority: Optional[int] = None,
|
|
228
|
+
idempotency_key: Optional[str] = None, webhook_url: Optional[str] = None) -> Any:
|
|
229
|
+
return self._run(self._create("/v1/async/task", _task(task_type, payload, priority, idempotency_key, webhook_url)))
|
|
230
|
+
|
|
231
|
+
def create_batch(self, tasks: List[Dict[str, Any]]) -> Any:
|
|
232
|
+
"""1 to MAX_BATCH tasks, each shaped like the create_task body (taskType, payload, ...). Raises ValueError outside
|
|
233
|
+
that range, before sending. A task without an idempotencyKey is given a generated one, so retrying the call
|
|
234
|
+
never creates it twice."""
|
|
235
|
+
if not 1 <= len(tasks) <= MAX_BATCH:
|
|
236
|
+
raise ValueError(f"a batch holds 1 to {MAX_BATCH} tasks, got {len(tasks)}")
|
|
237
|
+
tasks = [{**t, "idempotencyKey": t.get("idempotencyKey") or str(uuid.uuid4())} for t in tasks]
|
|
238
|
+
return self._run(self._create("/v1/async/task/batch", tasks))
|
|
239
|
+
|
|
240
|
+
def get_task(self, task_id: str) -> Any:
|
|
241
|
+
return self._run(self._data("GET", _task_path(task_id)))
|
|
242
|
+
|
|
243
|
+
def wait_for_task(self, task_id: str, timeout: float = WAIT_TIMEOUT_S, interval: float = 10.0) -> Any:
|
|
244
|
+
"""Polls until COMPLETED or FAILED, waiting with jittered backoff from 0.5 s up to `interval` seconds between
|
|
245
|
+
polls. Rate limits, server errors, timeouts and connection errors are polled through; other errors raise.
|
|
246
|
+
Raises TimeoutError after `timeout` seconds. For many tasks, prefer a webhook (`webhook_url`, verified with
|
|
247
|
+
`verify_webhook`) over polling."""
|
|
248
|
+
return self._run(self._wait(task_id, timeout, interval))
|
|
249
|
+
|
|
250
|
+
def _wait(self, task_id: str, timeout: float, interval: float) -> Steps[Dict[str, Any]]:
|
|
251
|
+
deadline = time.monotonic() + timeout
|
|
252
|
+
last: Optional[Exception] = None
|
|
253
|
+
poll = 0
|
|
254
|
+
while True:
|
|
255
|
+
remaining = deadline - time.monotonic()
|
|
256
|
+
if remaining <= 0:
|
|
257
|
+
raise TimeoutError(f"task {task_id} did not finish in time") from last
|
|
258
|
+
poller = self.with_options(timeout=min(remaining, self.timeout), max_retries=0)
|
|
259
|
+
try:
|
|
260
|
+
task, meta = yield from poller._request("GET", _task_path(task_id), None)
|
|
261
|
+
except (ApiError, httpx.TransportError) as err:
|
|
262
|
+
delay = _retry_delay(err, True, poll, interval)
|
|
263
|
+
if delay is None:
|
|
264
|
+
raise
|
|
265
|
+
last = err
|
|
266
|
+
else:
|
|
267
|
+
status = task.get("task", {}).get("status") if isinstance(task, dict) else None
|
|
268
|
+
if status in _TERMINAL:
|
|
269
|
+
return task
|
|
270
|
+
if status is None:
|
|
271
|
+
raise ApiError(meta.status, "MALFORMED_RESPONSE", task, meta)
|
|
272
|
+
delay = _backoff(poll, interval)
|
|
273
|
+
yield max(0.0, min(delay, deadline - time.monotonic()))
|
|
274
|
+
poll += 1
|
|
275
|
+
|
|
276
|
+
def async_status(self) -> Any:
|
|
277
|
+
return self._run(self._data("GET", "/v1/async/status"))
|
|
278
|
+
|
|
279
|
+
def clear_queue(self) -> Any:
|
|
280
|
+
"""Deletes QUEUED tasks (never charged). Clearing a full queue takes a while, so it gets a longer timeout
|
|
281
|
+
(the larger of `CLEAR_QUEUE_TIMEOUT_S` and the client's `timeout`)."""
|
|
282
|
+
return self._run(self._data("DELETE", "/v1/async/queue", timeout=max(CLEAR_QUEUE_TIMEOUT_S, self.timeout)))
|
|
283
|
+
|
|
284
|
+
# -- utility ----------------------------------------------------------------
|
|
285
|
+
def credits(self) -> Any:
|
|
286
|
+
return self._run(self._data("GET", "/v1/credits"))
|
|
287
|
+
|
|
288
|
+
def countries(self, model: Optional[str] = None) -> Any:
|
|
289
|
+
query = f"?model={urllib.parse.quote(model, safe='')}" if model else ""
|
|
290
|
+
return self._run(self._data("GET", f"/v1/countries{query}"))
|
|
291
|
+
|
|
292
|
+
def states(self, country: str = "US") -> Any:
|
|
293
|
+
return self._run(self._data("GET", f"/v1/states?country={urllib.parse.quote(country, safe='')}"))
|
|
294
|
+
|
|
295
|
+
def google_goto(self, url: str) -> Any:
|
|
296
|
+
"""Resolves a Google result URL; the server answers within ~10 s, so this gets a short timeout,
|
|
297
|
+
not the sync-monitor one its `/v1/monitor/` prefix would give it."""
|
|
298
|
+
def steps() -> Steps[str]:
|
|
299
|
+
return (yield from self._data("POST", "/v1/monitor/google/goto", {"url": url}, timeout=15.0))["url"]
|
|
300
|
+
|
|
301
|
+
return self._run(steps())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class _Monitor:
|
|
305
|
+
"""The `client.monitor` namespace: one method per engine, each a thin wrapper over POST /v1/monitor/<engine>.
|
|
306
|
+
Calling it directly — `monitor(engine, **body)` — is the generic form."""
|
|
307
|
+
|
|
308
|
+
def __init__(self, api: _Api):
|
|
309
|
+
self._api = api
|
|
310
|
+
|
|
311
|
+
def __call__(self, engine: str, **body: Any) -> Any:
|
|
312
|
+
return self._api._run(self._api._data("POST", f"/v1/monitor/{engine}", body))
|
|
313
|
+
|
|
314
|
+
def chatgpt(self, prompt: str, country: str, **options: Any) -> Any:
|
|
315
|
+
return self("chatgpt", prompt=prompt, country=country, **options)
|
|
316
|
+
|
|
317
|
+
def perplexity(self, prompt: str, country: str, **options: Any) -> Any:
|
|
318
|
+
return self("perplexity", prompt=prompt, country=country, **options)
|
|
319
|
+
|
|
320
|
+
def gemini(self, prompt: str, country: str, **options: Any) -> Any:
|
|
321
|
+
return self("gemini", prompt=prompt, country=country, **options)
|
|
322
|
+
|
|
323
|
+
def copilot(self, prompt: str, country: str, **options: Any) -> Any:
|
|
324
|
+
return self("copilot", prompt=prompt, country=country, **options)
|
|
325
|
+
|
|
326
|
+
def grok(self, prompt: str, country: str, **options: Any) -> Any:
|
|
327
|
+
return self("grok", prompt=prompt, country=country, **options)
|
|
328
|
+
|
|
329
|
+
def aimode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None, **options: Any) -> Any:
|
|
330
|
+
return self("aimode", **_geo({"prompt": prompt}, country, gl, options))
|
|
331
|
+
|
|
332
|
+
def google(self, query: Optional[str] = None, country: Optional[str] = None, **options: Any) -> Any:
|
|
333
|
+
body = {**options}
|
|
334
|
+
if query is not None:
|
|
335
|
+
body["query"] = query
|
|
336
|
+
if country is not None:
|
|
337
|
+
body["country"] = country
|
|
338
|
+
return self("google", **body)
|
|
339
|
+
|
|
340
|
+
def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None, **options: Any) -> Any:
|
|
341
|
+
return self("google/news", **_geo({"query": query}, country, gl, options))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class _AsyncTasks:
|
|
345
|
+
"""The `client.async_tasks` namespace: `create`/`create_batch`/`get`/`wait`/`run`/`status`/`clear_queue`."""
|
|
346
|
+
|
|
347
|
+
def __init__(self, api: _Api):
|
|
348
|
+
self._api = api
|
|
349
|
+
|
|
350
|
+
def create(self, task_type: str, payload: Dict[str, Any], priority: Optional[int] = None,
|
|
351
|
+
idempotency_key: Optional[str] = None, webhook_url: Optional[str] = None) -> Any:
|
|
352
|
+
return self._api.create_task(task_type, payload, priority, idempotency_key, webhook_url)
|
|
353
|
+
|
|
354
|
+
def create_batch(self, tasks: List[Dict[str, Any]]) -> Any:
|
|
355
|
+
"""Like `create_batch`, also accepting snake_case keys (`task_type`, `idempotency_key`, `webhook_url`)
|
|
356
|
+
in each item."""
|
|
357
|
+
return self._api.create_batch([_task_keys(t) for t in tasks])
|
|
358
|
+
|
|
359
|
+
def get(self, task_id: str) -> Any:
|
|
360
|
+
return self._api.get_task(task_id)
|
|
361
|
+
|
|
362
|
+
def wait(self, task_id: str, timeout: float = WAIT_TIMEOUT_S, interval: float = 10.0) -> Any:
|
|
363
|
+
"""Polls to a terminal status, like `wait_for_task`."""
|
|
364
|
+
return self._api.wait_for_task(task_id, timeout, interval)
|
|
365
|
+
|
|
366
|
+
def run(self, task_type: str, payload: Dict[str, Any], priority: Optional[int] = None,
|
|
367
|
+
idempotency_key: Optional[str] = None, webhook_url: Optional[str] = None,
|
|
368
|
+
timeout: float = WAIT_TIMEOUT_S, interval: float = 10.0) -> Any:
|
|
369
|
+
"""Creates the task and returns once it reaches a terminal status (COMPLETED or FAILED)."""
|
|
370
|
+
api = self._api
|
|
371
|
+
|
|
372
|
+
def steps() -> Steps[Dict[str, Any]]:
|
|
373
|
+
created = yield from api._create("/v1/async/task", _task(task_type, payload, priority, idempotency_key, webhook_url))
|
|
374
|
+
return (yield from api._wait(created["task"]["id"], timeout, interval))
|
|
375
|
+
|
|
376
|
+
return api._run(steps())
|
|
377
|
+
|
|
378
|
+
def status(self) -> Any:
|
|
379
|
+
return self._api.async_status()
|
|
380
|
+
|
|
381
|
+
def clear_queue(self) -> Any:
|
|
382
|
+
"""Deletes QUEUED tasks (never charged)."""
|
|
383
|
+
return self._api.clear_queue()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class Client(_Api):
|
|
387
|
+
_http_class = httpx.Client
|
|
388
|
+
|
|
389
|
+
def _run(self, steps: Steps[T]) -> T:
|
|
390
|
+
outcome: Any = None
|
|
391
|
+
try:
|
|
392
|
+
while True:
|
|
393
|
+
step = steps.send(outcome)
|
|
394
|
+
try:
|
|
395
|
+
outcome = self._http.send(step) if isinstance(step, httpx.Request) else time.sleep(step)
|
|
396
|
+
except Exception as err: # handed to the call, which retries or raises it
|
|
397
|
+
outcome = err
|
|
398
|
+
except StopIteration as done:
|
|
399
|
+
return done.value
|
|
400
|
+
except httpx.TransportError as err:
|
|
401
|
+
raise _builtin(err) from err
|
|
402
|
+
|
|
403
|
+
def close(self) -> None:
|
|
404
|
+
if self._owns_http:
|
|
405
|
+
self._http.close()
|
|
406
|
+
|
|
407
|
+
def __enter__(self) -> "Client":
|
|
408
|
+
return self
|
|
409
|
+
|
|
410
|
+
def __exit__(self, *exc: Any) -> None:
|
|
411
|
+
self.close()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class AsyncClient(_Api):
|
|
415
|
+
_http_class = httpx.AsyncClient
|
|
416
|
+
|
|
417
|
+
async def _run(self, steps: Steps[T]) -> T:
|
|
418
|
+
outcome: Any = None
|
|
419
|
+
try:
|
|
420
|
+
while True:
|
|
421
|
+
step = steps.send(outcome)
|
|
422
|
+
try:
|
|
423
|
+
outcome = await (self._http.send(step) if isinstance(step, httpx.Request) else asyncio.sleep(step))
|
|
424
|
+
except Exception as err: # handed to the call, which retries or raises it
|
|
425
|
+
outcome = err
|
|
426
|
+
except StopIteration as done:
|
|
427
|
+
return done.value
|
|
428
|
+
except httpx.TransportError as err:
|
|
429
|
+
raise _builtin(err) from err
|
|
430
|
+
|
|
431
|
+
async def aclose(self) -> None:
|
|
432
|
+
if self._owns_http:
|
|
433
|
+
await self._http.aclose()
|
|
434
|
+
|
|
435
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
436
|
+
return self
|
|
437
|
+
|
|
438
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
439
|
+
await self.aclose()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _geo(body: Dict[str, Any], country: Optional[str], gl: Optional[str], options: Dict[str, Any]) -> Dict[str, Any]:
|
|
443
|
+
"""The spec's country/gl either-of: one must be given, both may be (`gl` then serves the engine's locale)."""
|
|
444
|
+
body = {**body, **options}
|
|
445
|
+
if country is not None:
|
|
446
|
+
body["country"] = country
|
|
447
|
+
if gl is not None:
|
|
448
|
+
body["gl"] = gl
|
|
449
|
+
if "country" not in body and "gl" not in body:
|
|
450
|
+
raise ValueError("country or gl is required")
|
|
451
|
+
return body
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _task_keys(task: Dict[str, Any]) -> Dict[str, Any]:
|
|
455
|
+
"""Translate snake_case keys in one async-task item to the wire's camelCase."""
|
|
456
|
+
out = dict(task)
|
|
457
|
+
for snake, camel in (("task_type", "taskType"), ("idempotency_key", "idempotencyKey")):
|
|
458
|
+
if snake in out:
|
|
459
|
+
out.setdefault(camel, out.pop(snake))
|
|
460
|
+
if "webhook_url" in out:
|
|
461
|
+
out.setdefault("webhook", {"url": out.pop("webhook_url")})
|
|
462
|
+
return out
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _parse(outcome: Union[httpx.Response, Exception]) -> Tuple[Any, Meta]:
|
|
466
|
+
if isinstance(outcome, Exception):
|
|
467
|
+
raise outcome
|
|
468
|
+
headers = outcome.headers
|
|
469
|
+
meta = Meta(outcome.status_code, headers, headers.get("X-Request-Id"),
|
|
470
|
+
**{name: _int(headers.get(header)) for name, header in _LIMIT_HEADERS.items()})
|
|
471
|
+
is_json, parsed = _decode(outcome.content)
|
|
472
|
+
if not outcome.is_success or not is_json:
|
|
473
|
+
error = parsed.get("error") if isinstance(parsed, dict) else None
|
|
474
|
+
code = error.get("code") if isinstance(error, dict) and isinstance(error.get("code"), str) else None
|
|
475
|
+
raise ApiError(outcome.status_code, code, parsed, meta)
|
|
476
|
+
return parsed, meta
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _builtin(err: httpx.TransportError) -> OSError:
|
|
480
|
+
return TimeoutError(str(err)) if isinstance(err, httpx.TimeoutException) else ConnectionError(str(err))
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _int(value: Optional[str]) -> Optional[int]:
|
|
484
|
+
try:
|
|
485
|
+
return int(value) if value is not None else None
|
|
486
|
+
except ValueError:
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _decode(raw: bytes) -> Tuple[bool, Any]:
|
|
491
|
+
"""(True, value) for an empty or JSON body, (False, text) otherwise."""
|
|
492
|
+
try:
|
|
493
|
+
return True, (json.loads(raw) if raw else None)
|
|
494
|
+
except ValueError:
|
|
495
|
+
return False, raw.decode("utf-8", "replace")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _retry_after(value: Optional[str]) -> Optional[float]:
|
|
499
|
+
"""`Retry-After` as delta-seconds or an HTTP date."""
|
|
500
|
+
if value is None:
|
|
501
|
+
return None
|
|
502
|
+
if value.strip().isdigit():
|
|
503
|
+
return float(value)
|
|
504
|
+
try:
|
|
505
|
+
at = email.utils.parsedate_to_datetime(value)
|
|
506
|
+
# A date without a zone is naive; `timestamp()` would read it as local time.
|
|
507
|
+
return max(0.0, (at if at.tzinfo else at.replace(tzinfo=timezone.utc)).timestamp() - time.time())
|
|
508
|
+
except (TypeError, ValueError):
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _backoff(attempt: int, cap: float) -> float:
|
|
513
|
+
return random.uniform(0, min(cap, _BACKOFF_S * 2 ** min(attempt, 16)))
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _replayable(method: str, body: Any) -> bool:
|
|
517
|
+
"""Whether repeating the request cannot create a second effect."""
|
|
518
|
+
if method.upper() in _IDEMPOTENT:
|
|
519
|
+
return True
|
|
520
|
+
tasks = body if isinstance(body, list) else [body]
|
|
521
|
+
return bool(tasks) and all(isinstance(t, dict) and isinstance(t.get("idempotencyKey"), str) for t in tasks)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _retry_delay(err: Exception, replayable: bool, attempt: int, cap: float) -> Optional[float]:
|
|
525
|
+
"""Seconds to wait before repeating after `err`, or None when it must not be repeated."""
|
|
526
|
+
if isinstance(err, ApiError):
|
|
527
|
+
if err.status != 429:
|
|
528
|
+
return _backoff(attempt, cap) if err.status >= 500 and replayable else None
|
|
529
|
+
after = _retry_after(err.meta.headers.get("Retry-After"))
|
|
530
|
+
return _backoff(attempt, cap) if after is None else (after if after <= _MAX_DELAY_S else None)
|
|
531
|
+
retry = isinstance(err, _NOT_SENT) or (replayable and isinstance(err, _TRANSIENT))
|
|
532
|
+
return _backoff(attempt, cap) if retry else None
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _task_path(task_id: str) -> str:
|
|
536
|
+
return f"/v1/async/task/{urllib.parse.quote(task_id, safe='')}"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _task(task_type: str, payload: Dict[str, Any], priority: Optional[int], idempotency_key: Optional[str], webhook_url: Optional[str]) -> Dict[str, Any]:
|
|
540
|
+
body: Dict[str, Any] = {"taskType": task_type, "payload": payload}
|
|
541
|
+
if priority is not None:
|
|
542
|
+
body["priority"] = priority
|
|
543
|
+
# A task without a key (None or empty, as in a batch) gets a generated one, so retrying the call never creates it
|
|
544
|
+
# twice and two keyless calls never share a key.
|
|
545
|
+
body["idempotencyKey"] = idempotency_key or str(uuid.uuid4())
|
|
546
|
+
if webhook_url is not None:
|
|
547
|
+
body["webhook"] = {"url": webhook_url}
|
|
548
|
+
return body
|
answerline/py.typed
ADDED
|
File without changes
|
answerline/webhooks.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Webhook signature verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional, Union
|
|
10
|
+
|
|
11
|
+
SIGNATURE_HEADER = "Webhook-Signature"
|
|
12
|
+
|
|
13
|
+
_TIMESTAMP = re.compile(r"t=([0-9]+)")
|
|
14
|
+
_SIGNATURE = re.compile(r"v1=([0-9a-f]{64})")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify_webhook(body: Union[bytes, str], header: Optional[str], secret: str, tolerance_seconds: float = 300) -> bool:
|
|
18
|
+
"""Whether `header` (the Webhook-Signature request header) signs the raw request `body` with `secret`, at a time
|
|
19
|
+
within `tolerance_seconds` of now. Pass the body exactly as received: parsed and re-serialized JSON does not verify.
|
|
20
|
+
During a secret rotation deliveries carry a signature for each secret, so either secret verifies."""
|
|
21
|
+
parts = (header or "").split(",")
|
|
22
|
+
timestamps = [m.group(1) for m in map(_TIMESTAMP.fullmatch, parts) if m]
|
|
23
|
+
signatures = [m.group(1) for m in map(_SIGNATURE.fullmatch, parts) if m]
|
|
24
|
+
if len(timestamps) != 1 or not signatures or abs(time.time() - int(timestamps[0])) > tolerance_seconds:
|
|
25
|
+
return False
|
|
26
|
+
content = body.encode() if isinstance(body, str) else body
|
|
27
|
+
expected = hmac.new(secret.encode(), f"{timestamps[0]}.".encode() + content, hashlib.sha256).hexdigest()
|
|
28
|
+
return any(hmac.compare_digest(expected, signature) for signature in signatures)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: answerline
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the AnswerLine answer and search monitoring API.
|
|
5
|
+
Author-email: AnswerLine <support@answerline.dev>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://answerline.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/hsdxpro/answerline
|
|
9
|
+
Keywords: answerline,ai,llm,search-api,monitoring,chatgpt,perplexity,gemini
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx<1,>=0.27
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# answerline
|
|
23
|
+
|
|
24
|
+
Python client for the [AnswerLine](https://answerline.dev) API — structured JSON answers from AI assistants
|
|
25
|
+
(ChatGPT, Perplexity, Gemini, Copilot, Grok) and Google surfaces (Search, News, AI Overview, AI Mode), on httpx.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install answerline
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from answerline import Client
|
|
33
|
+
|
|
34
|
+
with Client("sk_...") as client:
|
|
35
|
+
# Synchronous monitor calls: one method per engine (chatgpt, perplexity, gemini,
|
|
36
|
+
# copilot, grok, aimode, google, google_news), or client.monitor(engine, **body).
|
|
37
|
+
answer = client.monitor.chatgpt(prompt="Best CRM for a 5-person team?", country="US")
|
|
38
|
+
|
|
39
|
+
# Async tasks: create, poll, or run to a terminal status.
|
|
40
|
+
task = client.async_tasks.run(
|
|
41
|
+
"CHATGPT",
|
|
42
|
+
{"prompt": "Best CRM for a 5-person team?", "country": "US"},
|
|
43
|
+
idempotency_key="crm-1",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
client.credits() # {"remaining", "perCycle", "cycleResetsAt"}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`AsyncClient` has the same methods, awaitable. `async_tasks.create_batch` submits up to 500 tasks in one call;
|
|
50
|
+
`async_tasks.wait` polls a task to COMPLETED or FAILED; `async_tasks.status` and `async_tasks.clear_queue` manage
|
|
51
|
+
the queue. Prefer a webhook (`webhook_url=` on the task) over polling for many tasks — verify deliveries with
|
|
52
|
+
`verify_webhook(body, header, secret)` against the `Webhook-Signature` header.
|
|
53
|
+
|
|
54
|
+
Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, credit
|
|
55
|
+
and request-id headers); transport failures raise `TimeoutError` or `ConnectionError`. 429s and safely replayable
|
|
56
|
+
failures are retried with jittered backoff; `timeout`, `sync_timeout`, `max_retries` and `http_client` are
|
|
57
|
+
constructor options.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
answerline/__init__.py,sha256=jo_jb533_CU6dDtPlWLTPR7XZU_SyPHDjbVL59ESaQQ,26053
|
|
2
|
+
answerline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
answerline/webhooks.py,sha256=QpjzmRgBVId_SjNqdJZCCDiBihfo3kIXPN33p6WXI8s,1329
|
|
4
|
+
answerline-0.1.0.dist-info/licenses/LICENSE,sha256=WEk3fXEcZoGoQtMd3Cu5aLyGrVtKajmtDHZaUlpSvEE,1067
|
|
5
|
+
answerline-0.1.0.dist-info/METADATA,sha256=k426Kzvmp3PiA7yyaaN9lrsSMQsA6oCz9hdt5EkXDJg,2546
|
|
6
|
+
answerline-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
answerline-0.1.0.dist-info/top_level.txt,sha256=wUm1n1JjUDO4D_566FKGRgzdrz1gJK13l3n2tRYNGKY,11
|
|
8
|
+
answerline-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AnswerLine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
answerline
|