answerline 0.3.0__tar.gz → 0.6.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: answerline
3
- Version: 0.3.0
3
+ Version: 0.6.0
4
4
  Summary: Python client for the AnswerLine answer and search monitoring API.
5
5
  Author-email: AnswerLine <support@answerline.dev>
6
6
  License-Expression: MIT
@@ -23,7 +23,7 @@ Dynamic: license-file
23
23
  Python client for the [AnswerLine](https://answerline.dev) API — structured JSON answers from AI assistants
24
24
  (ChatGPT, Perplexity, Gemini, Copilot, Grok) and Google surfaces (Search, News, AI Overview, AI Mode), on httpx.
25
25
 
26
- ChatGPT, Gemini, Copilot, Google Search (with AI Overview), AI Mode and Google News are served today; Perplexity and Grok are paused for now and coming back soon. [Coverage](https://answerline.dev/docs/coverage) lists what is served and from which countries. A monitor call to a paused engine fails with a 404 and an async task with a 422, each saying so, and nothing is billed.
26
+ All eight engines are served. [Coverage](https://answerline.dev/docs/coverage) lists the countries each one is served from.
27
27
 
28
28
  ```bash
29
29
  pip install answerline
@@ -37,6 +37,10 @@ with Client("sk_...") as client:
37
37
  # copilot, grok, aimode, google, google_news), or client.monitor(engine, **body).
38
38
  answer = client.monitor.chatgpt(prompt="iPhone 17 or Galaxy S26?", country="US")
39
39
 
40
+ # A US state: ChatGPT answers from inside Texas; every other engine answers it for the whole US.
41
+ # A state implies country "US"; with another country it is refused with a 400.
42
+ texas = client.monitor.chatgpt(prompt="Best pickup truck for towing?", state="TX")
43
+
40
44
  # Async tasks: create, poll, or run to a terminal status.
41
45
  task = client.async_tasks.run(
42
46
  "CHATGPT",
@@ -44,7 +48,8 @@ with Client("sk_...") as client:
44
48
  idempotency_key="phone-compare-1",
45
49
  )
46
50
 
47
- client.credits() # {"remaining", "perCycle", "cycleResetsAt"}
51
+ client.credits() # {"remaining": USD balance, "perCycle": None, "cycleResetsAt": None}
52
+ client.engines() # {"windowRequests": n, "engines": [{"taskType", "name", "status", "successRate", "yourSuccessRate", "yourRequests"}]}
48
53
  ```
49
54
 
50
55
  `AsyncClient` has the same methods, awaitable. `async_tasks.create_batch` submits up to 500 tasks in one call;
@@ -52,7 +57,7 @@ with Client("sk_...") as client:
52
57
  the queue. Prefer a webhook (`webhook_url=` on the task) over polling for many tasks — verify deliveries with
53
58
  `verify_webhook(body, header, secret)` against the `Webhook-Signature` header.
54
59
 
55
- Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, credit
60
+ Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, USD balance
56
61
  and request-id headers); transport failures raise `TimeoutError` or `ConnectionError`. 429s and safely replayable
57
62
  failures are retried with jittered backoff; `timeout`, `sync_timeout`, `max_retries` and `http_client` are
58
63
  constructor options.
@@ -62,7 +67,8 @@ constructor options.
62
67
  One answer is one draw from an engine that varies its answers. `visibility.measure` asks each engine the same prompt
63
68
  `samples` times as separate async tasks and returns the results per engine; with a `brand` it also runs
64
69
  `brand_check` over them: how many answers mention the brand (with a Wilson 95% interval), its average rank among the
65
- brands you list, how many cite its own domains, and which domains get cited when it is absent.
70
+ brands you list, how many cite its own domains, and which domains get cited when it is absent. `samples` is any whole
71
+ number from 1 (default 10); a round of more than 500 tasks goes out in several batch calls.
66
72
 
67
73
  ```python
68
74
  from answerline import brand_check
@@ -3,7 +3,7 @@
3
3
  Python client for the [AnswerLine](https://answerline.dev) API — structured JSON answers from AI assistants
4
4
  (ChatGPT, Perplexity, Gemini, Copilot, Grok) and Google surfaces (Search, News, AI Overview, AI Mode), on httpx.
5
5
 
6
- ChatGPT, Gemini, Copilot, Google Search (with AI Overview), AI Mode and Google News are served today; Perplexity and Grok are paused for now and coming back soon. [Coverage](https://answerline.dev/docs/coverage) lists what is served and from which countries. A monitor call to a paused engine fails with a 404 and an async task with a 422, each saying so, and nothing is billed.
6
+ All eight engines are served. [Coverage](https://answerline.dev/docs/coverage) lists the countries each one is served from.
7
7
 
8
8
  ```bash
9
9
  pip install answerline
@@ -17,6 +17,10 @@ with Client("sk_...") as client:
17
17
  # copilot, grok, aimode, google, google_news), or client.monitor(engine, **body).
18
18
  answer = client.monitor.chatgpt(prompt="iPhone 17 or Galaxy S26?", country="US")
19
19
 
20
+ # A US state: ChatGPT answers from inside Texas; every other engine answers it for the whole US.
21
+ # A state implies country "US"; with another country it is refused with a 400.
22
+ texas = client.monitor.chatgpt(prompt="Best pickup truck for towing?", state="TX")
23
+
20
24
  # Async tasks: create, poll, or run to a terminal status.
21
25
  task = client.async_tasks.run(
22
26
  "CHATGPT",
@@ -24,7 +28,8 @@ with Client("sk_...") as client:
24
28
  idempotency_key="phone-compare-1",
25
29
  )
26
30
 
27
- client.credits() # {"remaining", "perCycle", "cycleResetsAt"}
31
+ client.credits() # {"remaining": USD balance, "perCycle": None, "cycleResetsAt": None}
32
+ client.engines() # {"windowRequests": n, "engines": [{"taskType", "name", "status", "successRate", "yourSuccessRate", "yourRequests"}]}
28
33
  ```
29
34
 
30
35
  `AsyncClient` has the same methods, awaitable. `async_tasks.create_batch` submits up to 500 tasks in one call;
@@ -32,7 +37,7 @@ with Client("sk_...") as client:
32
37
  the queue. Prefer a webhook (`webhook_url=` on the task) over polling for many tasks — verify deliveries with
33
38
  `verify_webhook(body, header, secret)` against the `Webhook-Signature` header.
34
39
 
35
- Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, credit
40
+ Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, USD balance
36
41
  and request-id headers); transport failures raise `TimeoutError` or `ConnectionError`. 429s and safely replayable
37
42
  failures are retried with jittered backoff; `timeout`, `sync_timeout`, `max_retries` and `http_client` are
38
43
  constructor options.
@@ -42,7 +47,8 @@ constructor options.
42
47
  One answer is one draw from an engine that varies its answers. `visibility.measure` asks each engine the same prompt
43
48
  `samples` times as separate async tasks and returns the results per engine; with a `brand` it also runs
44
49
  `brand_check` over them: how many answers mention the brand (with a Wilson 95% interval), its average rank among the
45
- brands you list, how many cite its own domains, and which domains get cited when it is absent.
50
+ brands you list, how many cite its own domains, and which domains get cited when it is absent. `samples` is any whole
51
+ number from 1 (default 10); a round of more than 500 tasks goes out in several batch calls.
46
52
 
47
53
  ```python
48
54
  from answerline import brand_check
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "answerline"
7
- version = "0.3.0"
7
+ version = "0.6.0"
8
8
  description = "Python client for the AnswerLine answer and search monitoring API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -43,26 +43,26 @@ import httpx
43
43
  from .visibility import brand_check, wilson
44
44
  from .webhooks import SIGNATURE_HEADER, verify_webhook
45
45
 
46
- __all__ = ["ANSWER_ENGINES", "MAX_BATCH", "MAX_SAMPLES", "ApiError", "AsyncClient", "Client", "Meta",
46
+ __all__ = ["ANSWER_ENGINES", "MAX_BATCH", "ApiError", "AsyncClient", "Client", "Meta",
47
47
  "SIGNATURE_HEADER", "brand_check", "verify_webhook", "wilson"]
48
48
 
49
49
  # Sent as `answerline-py/<version>`; keep in step with pyproject.toml.
50
- __version__ = "0.3.0"
50
+ __version__ = "0.6.0"
51
51
  MAX_BATCH = 500
52
52
  # The answer engines `visibility.measure` samples, each asked with `prompt` and `country`, and their task types.
53
53
  ANSWER_ENGINES = {"chatgpt": "CHATGPT", "perplexity": "PERPLEXITY", "gemini": "GEMINI", "copilot": "COPILOT",
54
54
  "grok": "GROK", "aimode": "AIMODE"}
55
- MAX_SAMPLES = 100
56
55
  DEFAULT_BASE_URL = "https://api.answerline.dev"
57
56
  TIMEOUT_S = 30.0
58
- # Sync monitor calls and task creation: the server answers a sync call within its 330 s deadline plus one result read
59
- # (6.5 s), and keeps resending a creation through a failover until 330 s, so the client never gives up just as the
57
+ # Sync monitor calls and task creation: the server answers a sync call within its 630 s deadline plus one result read
58
+ # (6.5 s), and keeps resending a creation through a failover until 630 s, so the client never gives up just as the
60
59
  # server answers.
61
- SYNC_TIMEOUT_S = 360.0
62
- # The server resends a clear that did not reach its dispatcher for up to 120 s, then allows the clear 120 s.
63
- CLEAR_QUEUE_TIMEOUT_S = 270.0
64
- # A task's longest run once started (5 minutes) and a wait for a slot, plus margin.
65
- WAIT_TIMEOUT_S = 660.0
60
+ SYNC_TIMEOUT_S = 660.0
61
+ # The server resends a clear that did not reach its dispatcher for up to 120 s, then answers within one 10 s call
62
+ # (202 `clearing` for a queue longer than that call clears), plus margin.
63
+ CLEAR_QUEUE_TIMEOUT_S = 150.0
64
+ # A task's longest run once started (10 minutes) and a wait for a slot, plus margin.
65
+ WAIT_TIMEOUT_S = 960.0
66
66
  MAX_RETRIES = 2
67
67
  _BACKOFF_S = 0.5
68
68
  _MAX_DELAY_S = 8.0
@@ -84,7 +84,7 @@ class _Secret:
84
84
 
85
85
  _IDEMPOTENT = frozenset({"GET", "HEAD", "PUT", "OPTIONS"})
86
86
  #: POSTs that create nothing and cost nothing, so repeating one is as safe as a GET: resolving a Google
87
- #: redirect link, whose contract documents transient upstream failures.
87
+ #: redirect link, whose contract documents transient failures from Google.
88
88
  _REPLAYABLE_PATHS = frozenset({"/v1/monitor/google/goto"})
89
89
  # No connection was made, so the request never left.
90
90
  _NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
@@ -97,9 +97,13 @@ _LIMIT_HEADERS = {
97
97
  "concurrent_limit": "X-Concurrent-Limit",
98
98
  "concurrent_current": "X-Concurrent-Current",
99
99
  "concurrent_remaining": "X-Concurrent-Remaining",
100
+ "latency_ms": "X-Latency-Ms",
101
+ }
102
+
103
+ # USD amount headers (up to six decimals); the integer map above refuses them.
104
+ _CREDIT_HEADERS = {
100
105
  "credits_remaining": "X-Credits-Remaining",
101
106
  "credits_charged": "X-Credits-Charged",
102
- "latency_ms": "X-Latency-Ms",
103
107
  }
104
108
 
105
109
  T = TypeVar("T")
@@ -121,8 +125,9 @@ class Meta:
121
125
  concurrent_limit: Optional[int] = None
122
126
  concurrent_current: Optional[int] = None
123
127
  concurrent_remaining: Optional[int] = None
124
- credits_remaining: Optional[int] = None
125
- credits_charged: Optional[int] = None
128
+ # Amounts are USD and fractional (up to 6 decimals).
129
+ credits_remaining: Optional[float] = None
130
+ credits_charged: Optional[float] = None
126
131
  latency_ms: Optional[int] = None
127
132
 
128
133
 
@@ -234,29 +239,31 @@ class _Api:
234
239
  """Visibility helpers: `client.visibility.measure(prompt=..., engines=[...], country=..., brand={...})`."""
235
240
  return _Visibility(self)
236
241
 
237
- def chatgpt(self, prompt: str, country: str, **options: Any) -> Any:
238
- return self.monitor("chatgpt", prompt=prompt, country=country, **options)
242
+ def chatgpt(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
243
+ return self.monitor("chatgpt", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
239
244
 
240
- def perplexity(self, prompt: str, country: str, **options: Any) -> Any:
241
- return self.monitor("perplexity", prompt=prompt, country=country, **options)
245
+ def perplexity(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
246
+ return self.monitor("perplexity", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
242
247
 
243
- def gemini(self, prompt: str, country: str, **options: Any) -> Any:
244
- return self.monitor("gemini", prompt=prompt, country=country, **options)
248
+ def gemini(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
249
+ return self.monitor("gemini", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
245
250
 
246
- def copilot(self, prompt: str, country: str, **options: Any) -> Any:
247
- return self.monitor("copilot", prompt=prompt, country=country, **options)
251
+ def copilot(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
252
+ return self.monitor("copilot", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
248
253
 
249
- def grok(self, prompt: str, country: str, **options: Any) -> Any:
250
- return self.monitor("grok", prompt=prompt, country=country, **options)
254
+ def grok(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
255
+ return self.monitor("grok", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
251
256
 
252
- def ai_mode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None, **options: Any) -> Any:
253
- return self.monitor("aimode", **_geo({"prompt": prompt}, country, gl, options))
257
+ def ai_mode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None, state: Optional[str] = None,
258
+ **options: Any) -> Any:
259
+ return self.monitor("aimode", **_geo({"prompt": prompt}, {"country": country, "gl": gl, "state": state}, options))
254
260
 
255
261
  def google(self, **options: Any) -> Any:
256
262
  return self.monitor("google", **options)
257
263
 
258
- def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None, **options: Any) -> Any:
259
- return self.monitor("google/news", **_geo({"query": query}, country, gl, options))
264
+ def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None, state: Optional[str] = None,
265
+ **options: Any) -> Any:
266
+ return self.monitor("google/news", **_geo({"query": query}, {"country": country, "gl": gl, "state": state}, options))
260
267
 
261
268
  # -- async tasks ----------------------------------------------------------
262
269
  def create_task(self, task_type: str, payload: Dict[str, Any], priority: Optional[int] = None,
@@ -295,6 +302,10 @@ class _Api:
295
302
  try:
296
303
  task, meta = yield from poller._request("GET", _task_path(task_id), None)
297
304
  except (ApiError, httpx.TransportError) as err:
305
+ # A poll given the time left that ran out of it is the deadline passing, whatever the clock now says: a
306
+ # timer can fire a few milliseconds early, and the poll that followed could never answer in time.
307
+ if isinstance(err, httpx.TimeoutException) and remaining <= self.timeout:
308
+ raise TimeoutError(f"task {task_id} did not finish in time") from err
298
309
  # A rate limit's Retry-After is waited out while it ends before the deadline; one past it raises now.
299
310
  delay = _retry_delay(err, True, poll, interval, deadline - time.monotonic())
300
311
  if delay is None:
@@ -314,14 +325,21 @@ class _Api:
314
325
  return self._run(self._data("GET", "/v1/async/status"))
315
326
 
316
327
  def clear_queue(self) -> Any:
317
- """Deletes QUEUED tasks (never charged). Clearing a full queue takes a while, so it gets a longer timeout
318
- (the larger of `CLEAR_QUEUE_TIMEOUT_S` and the client's `timeout`)."""
328
+ """Deletes QUEUED tasks (never charged): `{"success": true, "cleared": n}`, or `{"success": true, "clearing": true}`
329
+ when the queue is longer than one call clears and the rest is still being deleted (`status()` shows it shrink).
330
+ The server may first wait out a dispatcher failover, so it gets a longer timeout (the larger of
331
+ `CLEAR_QUEUE_TIMEOUT_S` and the client's `timeout`)."""
319
332
  return self._run(self._data("DELETE", "/v1/async/queue", timeout=max(CLEAR_QUEUE_TIMEOUT_S, self.timeout)))
320
333
 
321
334
  # -- utility ----------------------------------------------------------------
322
335
  def credits(self) -> Any:
323
336
  return self._run(self._data("GET", "/v1/credits"))
324
337
 
338
+ def engines(self) -> Any:
339
+ """Each engine you can call: `status` now, and its success rate over everyone's and your own newest requests
340
+ (100 with none)."""
341
+ return self._run(self._data("GET", "/v1/engines"))
342
+
325
343
  def countries(self, model: Optional[str] = None) -> Any:
326
344
  query = f"?model={urllib.parse.quote(model, safe='')}" if model else ""
327
345
  return self._run(self._data("GET", f"/v1/countries{query}"))
@@ -348,34 +366,33 @@ class _Monitor:
348
366
  def __call__(self, engine: str, **body: Any) -> Any:
349
367
  return self._api._run(self._api._data("POST", f"/v1/monitor/{engine}", body))
350
368
 
351
- def chatgpt(self, prompt: str, country: str, **options: Any) -> Any:
352
- return self("chatgpt", prompt=prompt, country=country, **options)
369
+ def chatgpt(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
370
+ return self("chatgpt", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
353
371
 
354
- def perplexity(self, prompt: str, country: str, **options: Any) -> Any:
355
- return self("perplexity", prompt=prompt, country=country, **options)
372
+ def perplexity(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
373
+ return self("perplexity", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
356
374
 
357
- def gemini(self, prompt: str, country: str, **options: Any) -> Any:
358
- return self("gemini", prompt=prompt, country=country, **options)
375
+ def gemini(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
376
+ return self("gemini", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
359
377
 
360
- def copilot(self, prompt: str, country: str, **options: Any) -> Any:
361
- return self("copilot", prompt=prompt, country=country, **options)
378
+ def copilot(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
379
+ return self("copilot", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
362
380
 
363
- def grok(self, prompt: str, country: str, **options: Any) -> Any:
364
- return self("grok", prompt=prompt, country=country, **options)
381
+ def grok(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
382
+ return self("grok", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
365
383
 
366
- def aimode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None, **options: Any) -> Any:
367
- return self("aimode", **_geo({"prompt": prompt}, country, gl, options))
384
+ def aimode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None, state: Optional[str] = None,
385
+ **options: Any) -> Any:
386
+ return self("aimode", **_geo({"prompt": prompt}, {"country": country, "gl": gl, "state": state}, options))
368
387
 
369
- def google(self, query: Optional[str] = None, country: Optional[str] = None, **options: Any) -> Any:
370
- body = {**options}
371
- if query is not None:
372
- body["query"] = query
373
- if country is not None:
374
- body["country"] = country
375
- return self("google", **body)
388
+ def google(self, query: Optional[str] = None, country: Optional[str] = None, state: Optional[str] = None,
389
+ **options: Any) -> Any:
390
+ given = {"query": query, "country": country, "state": state}
391
+ return self("google", **{**options, **{k: v for k, v in given.items() if v is not None}})
376
392
 
377
- def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None, **options: Any) -> Any:
378
- return self("google/news", **_geo({"query": query}, country, gl, options))
393
+ def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None, state: Optional[str] = None,
394
+ **options: Any) -> Any:
395
+ return self("google/news", **_geo({"query": query}, {"country": country, "gl": gl, "state": state}, options))
379
396
 
380
397
 
381
398
  class _AsyncTasks:
@@ -429,8 +446,8 @@ class _Visibility:
429
446
  def measure(self, prompt: str, engines: List[str], country: str, samples: int = 10,
430
447
  include: Optional[Dict[str, Any]] = None, rounds: int = 1, spacing_seconds: float = 0.0,
431
448
  brand: Optional[Dict[str, Any]] = None, competitors: Optional[List[Dict[str, Any]]] = None,
432
- timeout: float = WAIT_TIMEOUT_S, interval: float = 10.0) -> Any:
433
- """Asks each engine (keys of `ANSWER_ENGINES`) the same prompt `samples` times (1 to `MAX_SAMPLES`) as
449
+ timeout: float = WAIT_TIMEOUT_S, interval: float = 10.0, state: Optional[str] = None) -> Any:
450
+ """Asks each engine (keys of `ANSWER_ENGINES`) the same prompt `samples` times (an integer, 1 or more) as
434
451
  independent async tasks, each under its own idempotency key, waits for them and returns per engine
435
452
  `{"requested", "completed", "failed", "results", "errors", "unfinished"}`, plus `"brand_check"` (see
436
453
  `brand_check`) when a `brand` is given. `unfinished` lists the ids of submitted tasks whose wait failed: they may
@@ -440,7 +457,8 @@ class _Visibility:
440
457
  failed and the rest go on; only cancellation propagates.
441
458
 
442
459
  `rounds` splits each engine's samples into that many batches, `spacing_seconds` apart start to start (default:
443
- one round, all at once). `include` is sent with every sample. `timeout` is one deadline for all the waits,
460
+ one round, all at once). `include` and `state` (a US state code: ChatGPT answers from inside it, the other
461
+ engines for the whole US) are sent with every sample. `timeout` is one deadline for all the waits,
444
462
  starting once the last round is submitted: each task's wait gets the time left, and a task still unfinished
445
463
  when it passes is a failed sample. `interval` applies to each wait, as in `wait_for_task`.
446
464
 
@@ -448,8 +466,8 @@ class _Visibility:
448
466
  or hours apart measure how much the engine varies its own answer to this prompt from this country, not what
449
467
  different users see. Small samples give wide intervals (3 of 5 is anywhere from 23% to 88%): take 10 or more
450
468
  per engine, spread over hours with `rounds` and `spacing_seconds`."""
451
- if isinstance(samples, bool) or not isinstance(samples, int) or not 1 <= samples <= MAX_SAMPLES:
452
- raise ValueError(f"samples is an integer from 1 to {MAX_SAMPLES}, got {samples!r}")
469
+ if isinstance(samples, bool) or not isinstance(samples, int) or samples < 1:
470
+ raise ValueError(f"samples is an integer of 1 or more, got {samples!r}")
453
471
  if isinstance(rounds, bool) or not isinstance(rounds, int) or not 1 <= rounds <= samples:
454
472
  raise ValueError(f"rounds is an integer from 1 to samples, got {rounds!r}")
455
473
  if not spacing_seconds >= 0:
@@ -457,6 +475,8 @@ class _Visibility:
457
475
  if not engines or len(set(engines)) < len(engines) or not all(e in ANSWER_ENGINES for e in engines):
458
476
  raise ValueError(f"engines are distinct answer engines ({', '.join(ANSWER_ENGINES)}), got {engines!r}")
459
477
  payload: Dict[str, Any] = {"prompt": prompt, "country": country}
478
+ if state is not None:
479
+ payload["state"] = state
460
480
  if include is not None:
461
481
  payload["include"] = include
462
482
  api = self._api
@@ -494,10 +514,16 @@ class _Visibility:
494
514
  # The tasks run concurrently on the server, so one deadline covers every wait: a stalled engine costs
495
515
  # `timeout` once.
496
516
  deadline = time.monotonic() + timeout
517
+ # A wait that timed out was given all the time left, so the deadline has passed for every task after it,
518
+ # whatever the clock says: a timer can fire a few milliseconds early, and a poll in that sliver never answers.
519
+ expired = False
497
520
  for engine, task_id in tasks:
498
521
  try:
522
+ if expired:
523
+ raise TimeoutError(f"task {task_id} did not finish in time")
499
524
  done = yield from api._wait(task_id, deadline - time.monotonic(), interval)
500
525
  except Exception as err:
526
+ expired = expired or isinstance(err, TimeoutError)
501
527
  fail(engine, err)
502
528
  out[engine]["unfinished"].append(task_id)
503
529
  continue
@@ -570,15 +596,14 @@ class AsyncClient(_Api):
570
596
  await self.aclose()
571
597
 
572
598
 
573
- def _geo(body: Dict[str, Any], country: Optional[str], gl: Optional[str], options: Dict[str, Any]) -> Dict[str, Any]:
574
- """The spec's country/gl either-of: one must be given, both may be (`gl` then serves the engine's locale)."""
575
- body = {**body, **options}
576
- if country is not None:
577
- body["country"] = country
578
- if gl is not None:
579
- body["gl"] = gl
580
- if "country" not in body and "gl" not in body:
581
- raise ValueError("country or gl is required")
599
+ def _geo(body: Dict[str, Any], geo: Dict[str, Optional[str]], options: Dict[str, Any]) -> Dict[str, Any]:
600
+ """The body with the geo fields given, over `options`; at least one of them is required. `state` is a US state code
601
+ (e.g. "TX") and implies country "US"; with any other country it is refused with a 400. ChatGPT answers from inside
602
+ the state; every other engine answers it for the whole US (https://answerline.dev/docs/coverage#states). Given both
603
+ `country` and `gl`, `gl` serves the engine's locale."""
604
+ body = {**body, **options, **{k: v for k, v in geo.items() if v is not None}}
605
+ if not body.keys() & geo.keys():
606
+ raise ValueError(f"{' or '.join(geo)} is required")
582
607
  return body
583
608
 
584
609
 
@@ -598,7 +623,8 @@ def _parse(outcome: Union[httpx.Response, Exception]) -> Tuple[Any, Meta]:
598
623
  raise outcome
599
624
  headers = outcome.headers
600
625
  meta = Meta(outcome.status_code, headers, headers.get("X-Request-Id"),
601
- **{name: _int(headers.get(header)) for name, header in _LIMIT_HEADERS.items()})
626
+ **{name: _int(headers.get(header)) for name, header in _LIMIT_HEADERS.items()},
627
+ **{name: _float(headers.get(header)) for name, header in _CREDIT_HEADERS.items()})
602
628
  is_json, parsed = _decode(outcome.content)
603
629
  if not outcome.is_success or not is_json:
604
630
  error = parsed.get("error") if isinstance(parsed, dict) else None
@@ -618,6 +644,13 @@ def _int(value: Optional[str]) -> Optional[int]:
618
644
  return None
619
645
 
620
646
 
647
+ def _float(value: Optional[str]) -> Optional[float]:
648
+ try:
649
+ return float(value) if value is not None else None
650
+ except ValueError:
651
+ return None
652
+
653
+
621
654
  def _decode(raw: bytes) -> Tuple[bool, Any]:
622
655
  """(True, value) for an empty or JSON body, (False, text) otherwise."""
623
656
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: answerline
3
- Version: 0.3.0
3
+ Version: 0.6.0
4
4
  Summary: Python client for the AnswerLine answer and search monitoring API.
5
5
  Author-email: AnswerLine <support@answerline.dev>
6
6
  License-Expression: MIT
@@ -23,7 +23,7 @@ Dynamic: license-file
23
23
  Python client for the [AnswerLine](https://answerline.dev) API — structured JSON answers from AI assistants
24
24
  (ChatGPT, Perplexity, Gemini, Copilot, Grok) and Google surfaces (Search, News, AI Overview, AI Mode), on httpx.
25
25
 
26
- ChatGPT, Gemini, Copilot, Google Search (with AI Overview), AI Mode and Google News are served today; Perplexity and Grok are paused for now and coming back soon. [Coverage](https://answerline.dev/docs/coverage) lists what is served and from which countries. A monitor call to a paused engine fails with a 404 and an async task with a 422, each saying so, and nothing is billed.
26
+ All eight engines are served. [Coverage](https://answerline.dev/docs/coverage) lists the countries each one is served from.
27
27
 
28
28
  ```bash
29
29
  pip install answerline
@@ -37,6 +37,10 @@ with Client("sk_...") as client:
37
37
  # copilot, grok, aimode, google, google_news), or client.monitor(engine, **body).
38
38
  answer = client.monitor.chatgpt(prompt="iPhone 17 or Galaxy S26?", country="US")
39
39
 
40
+ # A US state: ChatGPT answers from inside Texas; every other engine answers it for the whole US.
41
+ # A state implies country "US"; with another country it is refused with a 400.
42
+ texas = client.monitor.chatgpt(prompt="Best pickup truck for towing?", state="TX")
43
+
40
44
  # Async tasks: create, poll, or run to a terminal status.
41
45
  task = client.async_tasks.run(
42
46
  "CHATGPT",
@@ -44,7 +48,8 @@ with Client("sk_...") as client:
44
48
  idempotency_key="phone-compare-1",
45
49
  )
46
50
 
47
- client.credits() # {"remaining", "perCycle", "cycleResetsAt"}
51
+ client.credits() # {"remaining": USD balance, "perCycle": None, "cycleResetsAt": None}
52
+ client.engines() # {"windowRequests": n, "engines": [{"taskType", "name", "status", "successRate", "yourSuccessRate", "yourRequests"}]}
48
53
  ```
49
54
 
50
55
  `AsyncClient` has the same methods, awaitable. `async_tasks.create_batch` submits up to 500 tasks in one call;
@@ -52,7 +57,7 @@ with Client("sk_...") as client:
52
57
  the queue. Prefer a webhook (`webhook_url=` on the task) over polling for many tasks — verify deliveries with
53
58
  `verify_webhook(body, header, secret)` against the `Webhook-Signature` header.
54
59
 
55
- Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, credit
60
+ Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, USD balance
56
61
  and request-id headers); transport failures raise `TimeoutError` or `ConnectionError`. 429s and safely replayable
57
62
  failures are retried with jittered backoff; `timeout`, `sync_timeout`, `max_retries` and `http_client` are
58
63
  constructor options.
@@ -62,7 +67,8 @@ constructor options.
62
67
  One answer is one draw from an engine that varies its answers. `visibility.measure` asks each engine the same prompt
63
68
  `samples` times as separate async tasks and returns the results per engine; with a `brand` it also runs
64
69
  `brand_check` over them: how many answers mention the brand (with a Wilson 95% interval), its average rank among the
65
- brands you list, how many cite its own domains, and which domains get cited when it is absent.
70
+ brands you list, how many cite its own domains, and which domains get cited when it is absent. `samples` is any whole
71
+ number from 1 (default 10); a round of more than 500 tasks goes out in several batch calls.
66
72
 
67
73
  ```python
68
74
  from answerline import brand_check
@@ -10,8 +10,6 @@ src/answerline.egg-info/SOURCES.txt
10
10
  src/answerline.egg-info/dependency_links.txt
11
11
  src/answerline.egg-info/requires.txt
12
12
  src/answerline.egg-info/top_level.txt
13
- tests/localhost-cert.pem
14
- tests/localhost-key.pem
15
13
  tests/test_client.py
16
14
  tests/test_visibility.py
17
15
  tests/test_webhooks.py
@@ -360,7 +360,7 @@ class ClientTest(ServerTest):
360
360
  self.new_client(timeout=0.01).clear_queue()
361
361
  self.assertEqual(self.sleep.call_count, 0, "a timed-out clear may still be running: not repeated")
362
362
  await_seen(self, 1)
363
- self.assertEqual(answerline.CLEAR_QUEUE_TIMEOUT_S, 270.0)
363
+ self.assertEqual(answerline.CLEAR_QUEUE_TIMEOUT_S, 150.0)
364
364
 
365
365
  def test_dropped_connections_retry_get_but_not_sync_calls(self):
366
366
  REPLIES.extend(["reset", task_reply("QUEUED")])
@@ -546,13 +546,21 @@ class ClientTest(ServerTest):
546
546
  self.assertEqual([e.code for e in out["chatgpt"]["errors"]], ["INSUFFICIENT_CREDITS", "INSUFFICIENT_CREDITS", "NOT_FOUND"])
547
547
 
548
548
  def test_visibility_measure_refuses_bad_options_before_sending(self):
549
- for options in ({"samples": 0}, {"samples": 101}, {"samples": 2.5}, {"samples": 3, "rounds": 4}, {"rounds": 0},
549
+ for options in ({"samples": 0}, {"samples": -1}, {"samples": 2.5}, {"samples": 3, "rounds": 4}, {"rounds": 0},
550
550
  {"spacing_seconds": -1}, {"engines": []}, {"engines": ["chatgpt", "chatgpt"]},
551
551
  {"engines": ["google"]}):
552
552
  with self.subTest(options=options), self.assertRaises(ValueError):
553
553
  self.client.visibility.measure(**{"prompt": "x", "engines": ["chatgpt"], "country": "US", **options})
554
554
  self.assertEqual(SEEN, [])
555
555
 
556
+ def test_visibility_measure_takes_any_number_of_samples(self):
557
+ # No upper bound: 501 samples, past the 100 it once allowed, go out in batches of at most MAX_BATCH.
558
+ self.assertFalse(hasattr(answerline, "MAX_SAMPLES"))
559
+ REPLIES.extend([error(403, "INSUFFICIENT_CREDITS"), error(403, "INSUFFICIENT_CREDITS")])
560
+ out = self.client.visibility.measure("x", ["chatgpt"], "US", samples=501)
561
+ self.assertEqual([len(json.loads(s[3])) for s in SEEN], [MAX_BATCH, 1])
562
+ self.assertEqual((out["chatgpt"]["requested"], out["chatgpt"]["failed"]), (501, 501))
563
+
556
564
 
557
565
  class AsyncClientTest(ServerTest, unittest.IsolatedAsyncioTestCase):
558
566
  """The asyncio client runs the same calls; these check that its runner sends, waits and maps errors alike."""
@@ -1,11 +0,0 @@
1
- -----BEGIN CERTIFICATE-----
2
- MIIBoDCCAUagAwIBAgIUGl4E2FTh5mbDrsvR/CGSuY14tIEwCgYIKoZIzj0EAwIw
3
- FDESMBAGA1UEAwwJMTI3LjAuMC4xMCAXDTI2MDkxNDIxMTAxMloYDzIxMjYwODIx
4
- MjExMDEyWjAUMRIwEAYDVQQDDAkxMjcuMC4wLjEwWTATBgcqhkjOPQIBBggqhkjO
5
- PQMBBwNCAAQWPixZ2ME9prdR+m7sipWKOVmIwrXlZ32aO+ySiIECSTS3+1po3bFB
6
- mjKW1we39G5F1LHZfKSdP8vMc6wE4guao3QwcjAdBgNVHQ4EFgQU5FOuodtWI4IR
7
- gdfnJIcIirPf+AkwHwYDVR0jBBgwFoAU5FOuodtWI4IRgdfnJIcIirPf+AkwDwYD
8
- VR0TAQH/BAUwAwEB/zAPBgNVHREECDAGhwR/AAABMA4GA1UdDwEB/wQEAwIChDAK
9
- BggqhkjOPQQDAgNIADBFAiEAxy0k552unmVHuD2oBV5/ZoehGRqC5M/GSJGd+smc
10
- s7UCIGi3F3Y1o+ATHldxx71BjZojy+AGgYx2orlI7zhQsICg
11
- -----END CERTIFICATE-----
@@ -1,5 +0,0 @@
1
- -----BEGIN PRIVATE KEY-----
2
- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXVxxNTv2/fq/HUeS
3
- jZwEdhwMaON64AhCmr+BBedFfwuhRANCAAQWPixZ2ME9prdR+m7sipWKOVmIwrXl
4
- Z32aO+ySiIECSTS3+1po3bFBmjKW1we39G5F1LHZfKSdP8vMc6wE4gua
5
- -----END PRIVATE KEY-----
File without changes
File without changes