frontmost 0.2.1__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.
- frontmost-0.2.1/.gitignore +5 -0
- frontmost-0.2.1/LICENSE +21 -0
- frontmost-0.2.1/PKG-INFO +253 -0
- frontmost-0.2.1/README.md +231 -0
- frontmost-0.2.1/examples/weekly_report.py +28 -0
- frontmost-0.2.1/pyproject.toml +46 -0
- frontmost-0.2.1/src/frontmost/__init__.py +21 -0
- frontmost-0.2.1/src/frontmost/_core.py +501 -0
- frontmost-0.2.1/src/frontmost/_generated.py +1475 -0
- frontmost-0.2.1/src/frontmost/py.typed +0 -0
- frontmost-0.2.1/tests/test_conformance.py +148 -0
- frontmost-0.2.1/uv.lock +709 -0
frontmost-0.2.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frontmost AI
|
|
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.
|
frontmost-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: frontmost
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: See when ChatGPT and other AI apps recommend your business, from your own code and agents.
|
|
5
|
+
Project-URL: Homepage, https://frontmost.ai
|
|
6
|
+
Project-URL: Documentation, https://github.com/frontmost-ai/sdks/tree/main/python#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/frontmost-ai/sdks
|
|
8
|
+
Project-URL: Issues, https://github.com/frontmost-ai/sdks/issues
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aeo,ai search,ai visibility,chatgpt,frontmost,geo,sdk
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: httpx<1,>=0.23
|
|
20
|
+
Requires-Dist: typing-extensions>=4.7
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Frontmost AI for Python
|
|
24
|
+
|
|
25
|
+
Find out when ChatGPT and other AI apps recommend your business, and who they recommend instead. This package gives your code and agents typed access to the [Frontmost AI API](https://app.frontmost.ai/api/v1/openapi.json).
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install https://github.com/frontmost-ai/sdks/releases/download/v0.2.0/frontmost-0.2.0-py3-none-any.whl
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Version 0.2.0 installs from [its GitHub release](https://github.com/frontmost-ai/sdks/releases/tag/v0.2.0). The packages go on npm, PyPI, and crates.io next.
|
|
32
|
+
|
|
33
|
+
It runs on Python 3.9 or later. It has a sync client and an async client, and full type hints.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
Create a key on [the Team page](https://app.frontmost.ai/team), then put it in `FRONTMOST_API_KEY`.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from frontmost import Frontmost, format_rate
|
|
41
|
+
|
|
42
|
+
client = Frontmost() # reads FRONTMOST_API_KEY
|
|
43
|
+
|
|
44
|
+
project = client.projects.list().to_list()[0]
|
|
45
|
+
metrics = client.metrics.get(project["id"], period="week", compare_to="previous")
|
|
46
|
+
|
|
47
|
+
totals = metrics["totals"]
|
|
48
|
+
print(format_rate(totals["visibility"] if totals else None)) # 22% · 8 of 36
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`format_rate` prints a rate with its counts. A rate never travels without them: `visibility` is `{"value": 0.22, "numerator": 8, "denominator": 36, "previous": 0.17}`.
|
|
52
|
+
|
|
53
|
+
Replies are typed dicts, so your editor knows every key. A full program is in [examples/weekly_report.py](examples/weekly_report.py).
|
|
54
|
+
|
|
55
|
+
## Read your numbers
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
metrics = client.metrics.get(
|
|
59
|
+
"90",
|
|
60
|
+
period="month", # "scan", "week", or "month"
|
|
61
|
+
app="chatgpt", # one AI app, or leave it out for every app
|
|
62
|
+
questions="unbranded", # "all", "branded", or "unbranded"
|
|
63
|
+
group_by="app", # split the numbers by AI app
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
totals = metrics["totals"]
|
|
67
|
+
if totals:
|
|
68
|
+
print(format_rate(totals["win_rate"]))
|
|
69
|
+
for group in metrics.get("groups", []):
|
|
70
|
+
print(group.get("app"), format_rate(group["visibility"]))
|
|
71
|
+
else:
|
|
72
|
+
print(metrics.get("message")) # why there is nothing to count yet, such as no delivered scan
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`metrics["scope"]` says exactly what the numbers count: the scan, the dates, the apps, and the question set.
|
|
76
|
+
|
|
77
|
+
## Who AI names instead of you
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
rivals = client.rivals.list("90", period="week").to_list()
|
|
81
|
+
|
|
82
|
+
for rival in sorted(rivals, key=lambda r: r["named_instead_of_you"], reverse=True):
|
|
83
|
+
print(f"{rival['name']}: named instead of you in {rival['named_instead_of_you']} answers")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Read the answers
|
|
87
|
+
|
|
88
|
+
Filter the answers, then open one to read its text and citations.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
# Answers that cite reddit.com and do not name you.
|
|
92
|
+
for answer in client.answers.list("90", cited_domain="reddit.com", named=False):
|
|
93
|
+
full = client.answers.get(answer["id"])
|
|
94
|
+
print(full["question"]["text"])
|
|
95
|
+
print(full["text"])
|
|
96
|
+
print([citation["url"] for citation in full["citations"]])
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Other filters: `question_id`, `rival_id`, `scan_id`, `period`, `app`, and `questions`.
|
|
100
|
+
|
|
101
|
+
## Lists and pages
|
|
102
|
+
|
|
103
|
+
A list method returns a page object. You never handle a cursor unless you want to.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
# Every row, one page at a time.
|
|
107
|
+
for question in client.questions.list("90"):
|
|
108
|
+
print(question["text"])
|
|
109
|
+
|
|
110
|
+
# Every row in one list.
|
|
111
|
+
sources = client.sources.list("90", channel="reddit").to_list()
|
|
112
|
+
|
|
113
|
+
# One page, with the list's own fields.
|
|
114
|
+
page = client.rivals.list("90", limit=10).page()
|
|
115
|
+
print(page["scope"], page["has_more"], page["next_cursor"])
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Change what Frontmost AI tracks
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# Add buyer questions.
|
|
122
|
+
added = client.questions.create(
|
|
123
|
+
"90",
|
|
124
|
+
{"questions": [{"text": "Which AI visibility tool works without a dashboard?", "topic": "Tools"}]},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Pause a question.
|
|
128
|
+
client.questions.update("90", added["data"][0]["id"], {"active": False})
|
|
129
|
+
|
|
130
|
+
# Add a buyer profile.
|
|
131
|
+
client.profiles.create("90", {"name": "Agency owner", "who": "Runs a five-person SEO agency"})
|
|
132
|
+
|
|
133
|
+
# Mark a recommendation as done.
|
|
134
|
+
open_items = client.recommendations.list("90", status="open").to_list()
|
|
135
|
+
client.recommendations.update(open_items[0]["id"], {"status": "done"})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Add a project
|
|
139
|
+
|
|
140
|
+
Frontmost AI reads the website before it writes the first questions. `create_and_wait` waits for that and returns the project.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
project = client.projects.create_and_wait({"domain": "example.com", "country": "US"})
|
|
144
|
+
print(project["url"])
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`create` returns at once. Its reply is a project, or a setup that tells you when to call again:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
reply = client.projects.create({"domain": "example.com"})
|
|
151
|
+
if reply["object"] == "project_setup":
|
|
152
|
+
... # Wait reply["retry_after"] seconds, then send reply["next"]["body"] to create again.
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`create_and_wait` stops with an error after 10 minutes. The error message names the call that continues the setup.
|
|
156
|
+
|
|
157
|
+
## Async
|
|
158
|
+
|
|
159
|
+
`AsyncFrontmost` has the same methods. Use `await` for a call and `async for` for a list.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
import asyncio
|
|
163
|
+
|
|
164
|
+
from frontmost import AsyncFrontmost
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def main() -> None:
|
|
168
|
+
async with AsyncFrontmost() as client:
|
|
169
|
+
metrics, rivals = await asyncio.gather(
|
|
170
|
+
client.metrics.get("90", period="week"),
|
|
171
|
+
client.rivals.list("90", period="week").to_list(),
|
|
172
|
+
)
|
|
173
|
+
async for answer in client.answers.list("90", named=False, limit=5):
|
|
174
|
+
print(answer["question"]["text"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
asyncio.run(main())
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Errors
|
|
181
|
+
|
|
182
|
+
Each API error is an `APIError` subclass with the status, the API's `code`, its message, and the `request_id`.
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from frontmost import APIError, NotFoundError
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
client.projects.get("12345")
|
|
189
|
+
except NotFoundError as err:
|
|
190
|
+
print(err.message) # the API says what to do next
|
|
191
|
+
except APIError as err:
|
|
192
|
+
print(err.status, err.code, err.request_id)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
| Status | Class |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| 400 | `BadRequestError` |
|
|
198
|
+
| 401 | `AuthenticationError` |
|
|
199
|
+
| 403 | `PermissionDeniedError` |
|
|
200
|
+
| 404 | `NotFoundError` |
|
|
201
|
+
| 409 | `ConflictError` |
|
|
202
|
+
| 422 | `UnprocessableEntityError` |
|
|
203
|
+
| 429 | `RateLimitError` |
|
|
204
|
+
| 5xx | `InternalServerError` |
|
|
205
|
+
| No reply | `APIConnectionError` |
|
|
206
|
+
| No reply in time | `APIConnectionTimeoutError` |
|
|
207
|
+
|
|
208
|
+
All of them extend `FrontmostError`.
|
|
209
|
+
|
|
210
|
+
## Retries and timeouts
|
|
211
|
+
|
|
212
|
+
The client sends a failed call again up to 2 times: on a timeout, a lost connection, 408, 429, a 5xx, and a 409 that says the same request is still running. It waits as long as `Retry-After` says, up to 60 seconds. With no `Retry-After`, it waits 0.5 s and doubles each time, up to 8 s.
|
|
213
|
+
|
|
214
|
+
Each attempt has 60 seconds. Change either value for the client or for one call:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
client = Frontmost(max_retries=4, timeout=20.0)
|
|
218
|
+
|
|
219
|
+
client.metrics.get("90", max_retries=0, timeout=5.0)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Safe writes
|
|
223
|
+
|
|
224
|
+
Each POST carries an `Idempotency-Key`. A retry sends the same key, so the API does the work once. To make your own retries safe too, pass your own key:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
client.questions.create("90", body, idempotency_key="questions-2026-11-16")
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Configuration
|
|
231
|
+
|
|
232
|
+
| Argument | Default |
|
|
233
|
+
| --- | --- |
|
|
234
|
+
| `api_key` | `FRONTMOST_API_KEY` |
|
|
235
|
+
| `base_url` | `FRONTMOST_BASE_URL`, then `https://app.frontmost.ai/api/v1` |
|
|
236
|
+
| `max_retries` | `2` |
|
|
237
|
+
| `timeout` | `60.0` seconds per attempt |
|
|
238
|
+
| `default_headers` | None |
|
|
239
|
+
| `http_client` | A new `httpx.Client` (`httpx.AsyncClient` for `AsyncFrontmost`) |
|
|
240
|
+
|
|
241
|
+
Every method also takes `max_retries`, `timeout`, `idempotency_key`, and `extra_headers` as keyword arguments. Close the client with `client.close()`, or use it in a `with` block.
|
|
242
|
+
|
|
243
|
+
## Types
|
|
244
|
+
|
|
245
|
+
Every request and reply has a `TypedDict`, exported by name: `Metrics`, `Rival`, `AnswerSummary`, `Answer`, `Rate`, `Scope`, and the rest. A list of values, such as `Channel`, is a `Literal`. The API can add a value to a list, so handle the values you do not know.
|
|
246
|
+
|
|
247
|
+
## Versions
|
|
248
|
+
|
|
249
|
+
This package follows [semver](https://semver.org). A new field never breaks your code. A removed or changed field comes only in a new major version, or a new minor version before 1.0. The code is generated from the API's OpenAPI file in [frontmost-ai/sdks](https://github.com/frontmost-ai/sdks).
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
MIT
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Frontmost AI for Python
|
|
2
|
+
|
|
3
|
+
Find out when ChatGPT and other AI apps recommend your business, and who they recommend instead. This package gives your code and agents typed access to the [Frontmost AI API](https://app.frontmost.ai/api/v1/openapi.json).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install https://github.com/frontmost-ai/sdks/releases/download/v0.2.0/frontmost-0.2.0-py3-none-any.whl
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Version 0.2.0 installs from [its GitHub release](https://github.com/frontmost-ai/sdks/releases/tag/v0.2.0). The packages go on npm, PyPI, and crates.io next.
|
|
10
|
+
|
|
11
|
+
It runs on Python 3.9 or later. It has a sync client and an async client, and full type hints.
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
Create a key on [the Team page](https://app.frontmost.ai/team), then put it in `FRONTMOST_API_KEY`.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from frontmost import Frontmost, format_rate
|
|
19
|
+
|
|
20
|
+
client = Frontmost() # reads FRONTMOST_API_KEY
|
|
21
|
+
|
|
22
|
+
project = client.projects.list().to_list()[0]
|
|
23
|
+
metrics = client.metrics.get(project["id"], period="week", compare_to="previous")
|
|
24
|
+
|
|
25
|
+
totals = metrics["totals"]
|
|
26
|
+
print(format_rate(totals["visibility"] if totals else None)) # 22% · 8 of 36
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`format_rate` prints a rate with its counts. A rate never travels without them: `visibility` is `{"value": 0.22, "numerator": 8, "denominator": 36, "previous": 0.17}`.
|
|
30
|
+
|
|
31
|
+
Replies are typed dicts, so your editor knows every key. A full program is in [examples/weekly_report.py](examples/weekly_report.py).
|
|
32
|
+
|
|
33
|
+
## Read your numbers
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
metrics = client.metrics.get(
|
|
37
|
+
"90",
|
|
38
|
+
period="month", # "scan", "week", or "month"
|
|
39
|
+
app="chatgpt", # one AI app, or leave it out for every app
|
|
40
|
+
questions="unbranded", # "all", "branded", or "unbranded"
|
|
41
|
+
group_by="app", # split the numbers by AI app
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
totals = metrics["totals"]
|
|
45
|
+
if totals:
|
|
46
|
+
print(format_rate(totals["win_rate"]))
|
|
47
|
+
for group in metrics.get("groups", []):
|
|
48
|
+
print(group.get("app"), format_rate(group["visibility"]))
|
|
49
|
+
else:
|
|
50
|
+
print(metrics.get("message")) # why there is nothing to count yet, such as no delivered scan
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`metrics["scope"]` says exactly what the numbers count: the scan, the dates, the apps, and the question set.
|
|
54
|
+
|
|
55
|
+
## Who AI names instead of you
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
rivals = client.rivals.list("90", period="week").to_list()
|
|
59
|
+
|
|
60
|
+
for rival in sorted(rivals, key=lambda r: r["named_instead_of_you"], reverse=True):
|
|
61
|
+
print(f"{rival['name']}: named instead of you in {rival['named_instead_of_you']} answers")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Read the answers
|
|
65
|
+
|
|
66
|
+
Filter the answers, then open one to read its text and citations.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# Answers that cite reddit.com and do not name you.
|
|
70
|
+
for answer in client.answers.list("90", cited_domain="reddit.com", named=False):
|
|
71
|
+
full = client.answers.get(answer["id"])
|
|
72
|
+
print(full["question"]["text"])
|
|
73
|
+
print(full["text"])
|
|
74
|
+
print([citation["url"] for citation in full["citations"]])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Other filters: `question_id`, `rival_id`, `scan_id`, `period`, `app`, and `questions`.
|
|
78
|
+
|
|
79
|
+
## Lists and pages
|
|
80
|
+
|
|
81
|
+
A list method returns a page object. You never handle a cursor unless you want to.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# Every row, one page at a time.
|
|
85
|
+
for question in client.questions.list("90"):
|
|
86
|
+
print(question["text"])
|
|
87
|
+
|
|
88
|
+
# Every row in one list.
|
|
89
|
+
sources = client.sources.list("90", channel="reddit").to_list()
|
|
90
|
+
|
|
91
|
+
# One page, with the list's own fields.
|
|
92
|
+
page = client.rivals.list("90", limit=10).page()
|
|
93
|
+
print(page["scope"], page["has_more"], page["next_cursor"])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Change what Frontmost AI tracks
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# Add buyer questions.
|
|
100
|
+
added = client.questions.create(
|
|
101
|
+
"90",
|
|
102
|
+
{"questions": [{"text": "Which AI visibility tool works without a dashboard?", "topic": "Tools"}]},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Pause a question.
|
|
106
|
+
client.questions.update("90", added["data"][0]["id"], {"active": False})
|
|
107
|
+
|
|
108
|
+
# Add a buyer profile.
|
|
109
|
+
client.profiles.create("90", {"name": "Agency owner", "who": "Runs a five-person SEO agency"})
|
|
110
|
+
|
|
111
|
+
# Mark a recommendation as done.
|
|
112
|
+
open_items = client.recommendations.list("90", status="open").to_list()
|
|
113
|
+
client.recommendations.update(open_items[0]["id"], {"status": "done"})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Add a project
|
|
117
|
+
|
|
118
|
+
Frontmost AI reads the website before it writes the first questions. `create_and_wait` waits for that and returns the project.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
project = client.projects.create_and_wait({"domain": "example.com", "country": "US"})
|
|
122
|
+
print(project["url"])
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`create` returns at once. Its reply is a project, or a setup that tells you when to call again:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
reply = client.projects.create({"domain": "example.com"})
|
|
129
|
+
if reply["object"] == "project_setup":
|
|
130
|
+
... # Wait reply["retry_after"] seconds, then send reply["next"]["body"] to create again.
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`create_and_wait` stops with an error after 10 minutes. The error message names the call that continues the setup.
|
|
134
|
+
|
|
135
|
+
## Async
|
|
136
|
+
|
|
137
|
+
`AsyncFrontmost` has the same methods. Use `await` for a call and `async for` for a list.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import asyncio
|
|
141
|
+
|
|
142
|
+
from frontmost import AsyncFrontmost
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def main() -> None:
|
|
146
|
+
async with AsyncFrontmost() as client:
|
|
147
|
+
metrics, rivals = await asyncio.gather(
|
|
148
|
+
client.metrics.get("90", period="week"),
|
|
149
|
+
client.rivals.list("90", period="week").to_list(),
|
|
150
|
+
)
|
|
151
|
+
async for answer in client.answers.list("90", named=False, limit=5):
|
|
152
|
+
print(answer["question"]["text"])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
asyncio.run(main())
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Errors
|
|
159
|
+
|
|
160
|
+
Each API error is an `APIError` subclass with the status, the API's `code`, its message, and the `request_id`.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from frontmost import APIError, NotFoundError
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
client.projects.get("12345")
|
|
167
|
+
except NotFoundError as err:
|
|
168
|
+
print(err.message) # the API says what to do next
|
|
169
|
+
except APIError as err:
|
|
170
|
+
print(err.status, err.code, err.request_id)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
| Status | Class |
|
|
174
|
+
| --- | --- |
|
|
175
|
+
| 400 | `BadRequestError` |
|
|
176
|
+
| 401 | `AuthenticationError` |
|
|
177
|
+
| 403 | `PermissionDeniedError` |
|
|
178
|
+
| 404 | `NotFoundError` |
|
|
179
|
+
| 409 | `ConflictError` |
|
|
180
|
+
| 422 | `UnprocessableEntityError` |
|
|
181
|
+
| 429 | `RateLimitError` |
|
|
182
|
+
| 5xx | `InternalServerError` |
|
|
183
|
+
| No reply | `APIConnectionError` |
|
|
184
|
+
| No reply in time | `APIConnectionTimeoutError` |
|
|
185
|
+
|
|
186
|
+
All of them extend `FrontmostError`.
|
|
187
|
+
|
|
188
|
+
## Retries and timeouts
|
|
189
|
+
|
|
190
|
+
The client sends a failed call again up to 2 times: on a timeout, a lost connection, 408, 429, a 5xx, and a 409 that says the same request is still running. It waits as long as `Retry-After` says, up to 60 seconds. With no `Retry-After`, it waits 0.5 s and doubles each time, up to 8 s.
|
|
191
|
+
|
|
192
|
+
Each attempt has 60 seconds. Change either value for the client or for one call:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
client = Frontmost(max_retries=4, timeout=20.0)
|
|
196
|
+
|
|
197
|
+
client.metrics.get("90", max_retries=0, timeout=5.0)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Safe writes
|
|
201
|
+
|
|
202
|
+
Each POST carries an `Idempotency-Key`. A retry sends the same key, so the API does the work once. To make your own retries safe too, pass your own key:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
client.questions.create("90", body, idempotency_key="questions-2026-11-16")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Configuration
|
|
209
|
+
|
|
210
|
+
| Argument | Default |
|
|
211
|
+
| --- | --- |
|
|
212
|
+
| `api_key` | `FRONTMOST_API_KEY` |
|
|
213
|
+
| `base_url` | `FRONTMOST_BASE_URL`, then `https://app.frontmost.ai/api/v1` |
|
|
214
|
+
| `max_retries` | `2` |
|
|
215
|
+
| `timeout` | `60.0` seconds per attempt |
|
|
216
|
+
| `default_headers` | None |
|
|
217
|
+
| `http_client` | A new `httpx.Client` (`httpx.AsyncClient` for `AsyncFrontmost`) |
|
|
218
|
+
|
|
219
|
+
Every method also takes `max_retries`, `timeout`, `idempotency_key`, and `extra_headers` as keyword arguments. Close the client with `client.close()`, or use it in a `with` block.
|
|
220
|
+
|
|
221
|
+
## Types
|
|
222
|
+
|
|
223
|
+
Every request and reply has a `TypedDict`, exported by name: `Metrics`, `Rival`, `AnswerSummary`, `Answer`, `Rate`, `Scope`, and the rest. A list of values, such as `Channel`, is a `Literal`. The API can add a value to a list, so handle the values you do not know.
|
|
224
|
+
|
|
225
|
+
## Versions
|
|
226
|
+
|
|
227
|
+
This package follows [semver](https://semver.org). A new field never breaks your code. A removed or changed field comes only in a new major version, or a new minor version before 1.0. The code is generated from the API's OpenAPI file in [frontmost-ai/sdks](https://github.com/frontmost-ai/sdks).
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Print this week's numbers for one project, the rivals that AI apps name instead of you,
|
|
2
|
+
and the answers that cite reddit.com but do not name you.
|
|
3
|
+
|
|
4
|
+
FRONTMOST_API_KEY=fmk_... python examples/weekly_report.py 90
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from frontmost import Frontmost, format_rate
|
|
10
|
+
|
|
11
|
+
project_id = sys.argv[1] if len(sys.argv) > 1 else "90"
|
|
12
|
+
client = Frontmost()
|
|
13
|
+
|
|
14
|
+
metrics = client.metrics.get(project_id, period="week", compare_to="previous")
|
|
15
|
+
totals = metrics["totals"]
|
|
16
|
+
if totals is None:
|
|
17
|
+
print(metrics.get("message"))
|
|
18
|
+
sys.exit(0)
|
|
19
|
+
print(f"Visibility {format_rate(totals['visibility'])}")
|
|
20
|
+
print(f"Win rate {format_rate(totals['win_rate'])}")
|
|
21
|
+
|
|
22
|
+
rivals = client.rivals.list(project_id, period="week").to_list()
|
|
23
|
+
rivals.sort(key=lambda rival: rival["named_instead_of_you"], reverse=True)
|
|
24
|
+
for rival in rivals[:3]:
|
|
25
|
+
print(f"{rival['name']} was named instead of you in {rival['named_instead_of_you']} answers")
|
|
26
|
+
|
|
27
|
+
for answer in client.answers.list(project_id, cited_domain="reddit.com", named=False, limit=5):
|
|
28
|
+
print(f"{answer['app']}: {answer['question']['text']}")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "frontmost"
|
|
3
|
+
version = "0.2.1"
|
|
4
|
+
description = "See when ChatGPT and other AI apps recommend your business, from your own code and agents."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
keywords = ["frontmost", "ai visibility", "aeo", "geo", "chatgpt", "ai search", "sdk"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["httpx>=0.23,<1", "typing-extensions>=4.7"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://frontmost.ai"
|
|
22
|
+
Documentation = "https://github.com/frontmost-ai/sdks/tree/main/python#readme"
|
|
23
|
+
Repository = "https://github.com/frontmost-ai/sdks"
|
|
24
|
+
Issues = "https://github.com/frontmost-ai/sdks/issues"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = ["pytest>=8", "mypy>=1.13"]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/frontmost"]
|
|
35
|
+
|
|
36
|
+
[tool.mypy]
|
|
37
|
+
strict = true
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
target-version = "py39"
|
|
41
|
+
line-length = 120
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
45
|
+
# One-line exception classes read best as one line.
|
|
46
|
+
ignore = ["E701"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The Frontmost AI API client. `from frontmost import Frontmost` and `Frontmost()` reads FRONTMOST_API_KEY."""
|
|
2
|
+
|
|
3
|
+
from ._core import VERSION as VERSION
|
|
4
|
+
from ._core import APIConnectionError as APIConnectionError
|
|
5
|
+
from ._core import APIConnectionTimeoutError as APIConnectionTimeoutError
|
|
6
|
+
from ._core import APIError as APIError
|
|
7
|
+
from ._core import AsyncPage as AsyncPage
|
|
8
|
+
from ._core import AuthenticationError as AuthenticationError
|
|
9
|
+
from ._core import BadRequestError as BadRequestError
|
|
10
|
+
from ._core import ConflictError as ConflictError
|
|
11
|
+
from ._core import FrontmostError as FrontmostError
|
|
12
|
+
from ._core import InternalServerError as InternalServerError
|
|
13
|
+
from ._core import ListPage as ListPage
|
|
14
|
+
from ._core import NotFoundError as NotFoundError
|
|
15
|
+
from ._core import PermissionDeniedError as PermissionDeniedError
|
|
16
|
+
from ._core import RateLimitError as RateLimitError
|
|
17
|
+
from ._core import RequestOptions as RequestOptions
|
|
18
|
+
from ._core import SyncPage as SyncPage
|
|
19
|
+
from ._core import UnprocessableEntityError as UnprocessableEntityError
|
|
20
|
+
from ._core import format_rate as format_rate
|
|
21
|
+
from ._generated import * # noqa: F403 (the generated module lists its names in __all__)
|