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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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