promptready-mcp 0.3.3__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.
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PromptReady / HydRoMo
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.
22
+
23
+ ---
24
+
25
+ Note: This license applies to the **PromptReady MCP client** source code only.
26
+ Use of the PromptReady cloud service (https://promptready.space) remains
27
+ subject to the PromptReady Terms of Service and Privacy Policy.
@@ -0,0 +1 @@
1
+ prune tests
@@ -0,0 +1,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: promptready-mcp
3
+ Version: 0.3.3
4
+ Summary: MCP server (stdio) for PromptReady — convert PDF/CSV to Markdown from AI agents.
5
+ Author: PromptReady
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://promptready.space
8
+ Project-URL: Documentation, https://github.com/hydrojwh/promptready-mcp#readme
9
+ Project-URL: Source, https://github.com/hydrojwh/promptready-mcp
10
+ Project-URL: Issues, https://github.com/hydrojwh/promptready-mcp/issues
11
+ Keywords: mcp,model-context-protocol,pdf,csv,markdown,ocr,claude,cursor
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
22
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: mcp<2,>=1.2.0
28
+ Requires-Dist: httpx<1,>=0.27
29
+ Dynamic: license-file
30
+
31
+ # PromptReady MCP
32
+
33
+ <!-- mcp-name: io.github.hydrojwh/promptready-mcp -->
34
+
35
+ Official [Model Context Protocol](https://modelcontextprotocol.io/) client for
36
+ [PromptReady](https://promptready.space) — convert PDF/CSV to Markdown from AI
37
+ agents (Grok, Claude Code, Cursor, and other MCP hosts).
38
+
39
+ **Same PromptReady account and credits as the web app.**
40
+
41
+ ## Features
42
+
43
+ - Browser Google login (tokens stay on your machine)
44
+ - `get_credits`, `convert_pdf`, `get_status`, `wait_and_download`
45
+ - Saved convert defaults (engine, tables, images) — not on every call
46
+ - Factory default: **PaddleOCR-VL**, tables on, images off
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install promptready-mcp
52
+ ```
53
+
54
+ Or run it without installing:
55
+
56
+ ```bash
57
+ uvx promptready-mcp
58
+ ```
59
+
60
+ <details>
61
+ <summary>From source</summary>
62
+
63
+ ```bash
64
+ git clone https://github.com/hydrojwh/promptready-mcp.git
65
+ cd promptready-mcp
66
+ python3 -m venv .venv && source .venv/bin/activate
67
+ pip install -e .
68
+ ```
69
+
70
+ </details>
71
+
72
+ ## Login (once per machine)
73
+
74
+ ```bash
75
+ promptready-mcp-login
76
+ ```
77
+
78
+ If you installed with `uvx`, the login command lives in the same package:
79
+
80
+ ```bash
81
+ uvx --from promptready-mcp promptready-mcp-login
82
+ ```
83
+
84
+ Either opens Google OAuth and saves credentials to
85
+ `~/.config/promptready/credentials.json` (file mode `0600`). That path is in your
86
+ home directory, so it survives `uvx` cache resets. You can also log in from inside
87
+ an MCP host by calling the `login` tool.
88
+
89
+ Supabase Auth must allow redirect:
90
+
91
+ `http://127.0.0.1:18765/callback`
92
+
93
+ <details>
94
+ <summary>Email login fallback</summary>
95
+
96
+ If browser-based Google login is not an option, the same command accepts email
97
+ and password:
98
+
99
+ ```bash
100
+ promptready-mcp-login --email you@x.com
101
+ ```
102
+
103
+ Leave out `--password` and you will be prompted for it instead — this keeps the
104
+ password out of your shell history.
105
+
106
+ </details>
107
+
108
+ Already have an access token? Set `PROMPTREADY_ACCESS_TOKEN` in the environment
109
+ (MCP host or shell). Environment variables take precedence over the saved
110
+ credentials file.
111
+
112
+ ## MCP host config
113
+
114
+ ### Fastest: let your AI agent install it
115
+
116
+ If you are already in an MCP-capable agent, skip the JSON editing and just
117
+ ask:
118
+
119
+ > Install the PromptReady MCP server for me. The PyPI package is
120
+ > `promptready-mcp` (stdio command `promptready-mcp`). Add it to your MCP
121
+ > config, then I will run the `login` tool.
122
+
123
+ In Claude Code the agent can use the built-in CLI:
124
+
125
+ ```bash
126
+ claude mcp add promptready -- promptready-mcp
127
+ ```
128
+
129
+ ### Reconnect after changing config
130
+
131
+ Hosts do not pick up MCP config changes mid-session. After changing the
132
+ config, restart the host or reconnect the server — in Claude Code, open the
133
+ `/mcp` panel and reconnect.
134
+
135
+ Seeing tools in the `/mcp` panel does **not** mean the server is connected:
136
+ the panel can list the tool catalog while the session has no live server,
137
+ and picking a tool from that list will not run it. If tools are listed but
138
+ calls fail, reconnect first.
139
+
140
+ ### Claude Code
141
+
142
+ ```bash
143
+ claude mcp add promptready -- promptready-mcp
144
+ ```
145
+
146
+ The default scope is `local` (this project only). Use `--scope user` to
147
+ register it for all your projects, or `--scope project` to share the
148
+ registration through a committed `.mcp.json`.
149
+
150
+ ### Cursor
151
+
152
+ Add to `~/.cursor/mcp.json` (or `.cursor/mcp.json` for a single project):
153
+
154
+ ```json
155
+ {
156
+ "mcpServers": {
157
+ "promptready": {
158
+ "command": "promptready-mcp"
159
+ }
160
+ }
161
+ }
162
+ ```
163
+
164
+ ### Grok
165
+
166
+ ```toml
167
+ [mcp_servers.promptready]
168
+ command = "promptready-mcp"
169
+ enabled = true
170
+ tool_timeout_sec = 3600
171
+ ```
172
+
173
+ ### Claude Desktop / generic JSON
174
+
175
+ ```json
176
+ {
177
+ "mcpServers": {
178
+ "promptready": {
179
+ "command": "promptready-mcp"
180
+ }
181
+ }
182
+ }
183
+ ```
184
+
185
+ No access token in config files required after login.
186
+
187
+ <details>
188
+ <summary>Host cannot find <code>promptready-mcp</code></summary>
189
+
190
+ GUI hosts start servers with a narrow `PATH`, so a console script installed by
191
+ `pip install --user` is often invisible to them — the host reports a spawn
192
+ failure or "server disconnected" rather than a missing command.
193
+
194
+ Two reliable fixes:
195
+
196
+ ```json
197
+ { "mcpServers": { "promptready": {
198
+ "command": "uvx", "args": ["promptready-mcp"] } } }
199
+ ```
200
+
201
+ or point at the absolute path of the script:
202
+ `/ABS/PATH/.venv/bin/promptready-mcp`.
203
+
204
+ </details>
205
+
206
+ ## Tools
207
+
208
+ | Tool | Purpose |
209
+ |------|---------|
210
+ | `login` / `logout` | Browser auth / clear local credentials |
211
+ | `get_credits` | Credit balance |
212
+ | `get_convert_settings` / `set_convert_settings` | Saved convert defaults |
213
+ | `convert_pdf` | Upload path → queue (optional `wait`) |
214
+ | `get_status` | Job status |
215
+ | `wait_and_download` | Poll + save `.md` |
216
+
217
+ Downloaded names follow the web app: `{name}_PaddleOCR-VL.md` (engine label).
218
+
219
+ Credits are deducted by the server when a job is queued, exactly as on the web
220
+ app. `convert_pdf(wait=True)` can run for a long time, so give the host a high
221
+ tool timeout.
222
+
223
+ ## Security
224
+
225
+ - Tokens are **never** hardcoded in this repository.
226
+ - Do **not** commit `~/.config/promptready/*` or `.env`.
227
+ - Only use the official package linked from https://promptready.space
228
+ - Vulnerability reports: see [SECURITY.md](SECURITY.md)
229
+
230
+ ## Service terms
231
+
232
+ Using the cloud API is subject to the
233
+ [Terms of Service](https://promptready.space/legal/terms-of-service.en.md) and
234
+ [Privacy Policy](https://promptready.space/legal/privacy-policy.en.md).
235
+ This MIT-licensed client does not grant free unlimited conversion.
236
+
237
+ ## Smoke test (no account)
238
+
239
+ ```bash
240
+ ./scripts/smoke_stdio.sh
241
+ ```
242
+
243
+ ## License
244
+
245
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,215 @@
1
+ # PromptReady MCP
2
+
3
+ <!-- mcp-name: io.github.hydrojwh/promptready-mcp -->
4
+
5
+ Official [Model Context Protocol](https://modelcontextprotocol.io/) client for
6
+ [PromptReady](https://promptready.space) — convert PDF/CSV to Markdown from AI
7
+ agents (Grok, Claude Code, Cursor, and other MCP hosts).
8
+
9
+ **Same PromptReady account and credits as the web app.**
10
+
11
+ ## Features
12
+
13
+ - Browser Google login (tokens stay on your machine)
14
+ - `get_credits`, `convert_pdf`, `get_status`, `wait_and_download`
15
+ - Saved convert defaults (engine, tables, images) — not on every call
16
+ - Factory default: **PaddleOCR-VL**, tables on, images off
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install promptready-mcp
22
+ ```
23
+
24
+ Or run it without installing:
25
+
26
+ ```bash
27
+ uvx promptready-mcp
28
+ ```
29
+
30
+ <details>
31
+ <summary>From source</summary>
32
+
33
+ ```bash
34
+ git clone https://github.com/hydrojwh/promptready-mcp.git
35
+ cd promptready-mcp
36
+ python3 -m venv .venv && source .venv/bin/activate
37
+ pip install -e .
38
+ ```
39
+
40
+ </details>
41
+
42
+ ## Login (once per machine)
43
+
44
+ ```bash
45
+ promptready-mcp-login
46
+ ```
47
+
48
+ If you installed with `uvx`, the login command lives in the same package:
49
+
50
+ ```bash
51
+ uvx --from promptready-mcp promptready-mcp-login
52
+ ```
53
+
54
+ Either opens Google OAuth and saves credentials to
55
+ `~/.config/promptready/credentials.json` (file mode `0600`). That path is in your
56
+ home directory, so it survives `uvx` cache resets. You can also log in from inside
57
+ an MCP host by calling the `login` tool.
58
+
59
+ Supabase Auth must allow redirect:
60
+
61
+ `http://127.0.0.1:18765/callback`
62
+
63
+ <details>
64
+ <summary>Email login fallback</summary>
65
+
66
+ If browser-based Google login is not an option, the same command accepts email
67
+ and password:
68
+
69
+ ```bash
70
+ promptready-mcp-login --email you@x.com
71
+ ```
72
+
73
+ Leave out `--password` and you will be prompted for it instead — this keeps the
74
+ password out of your shell history.
75
+
76
+ </details>
77
+
78
+ Already have an access token? Set `PROMPTREADY_ACCESS_TOKEN` in the environment
79
+ (MCP host or shell). Environment variables take precedence over the saved
80
+ credentials file.
81
+
82
+ ## MCP host config
83
+
84
+ ### Fastest: let your AI agent install it
85
+
86
+ If you are already in an MCP-capable agent, skip the JSON editing and just
87
+ ask:
88
+
89
+ > Install the PromptReady MCP server for me. The PyPI package is
90
+ > `promptready-mcp` (stdio command `promptready-mcp`). Add it to your MCP
91
+ > config, then I will run the `login` tool.
92
+
93
+ In Claude Code the agent can use the built-in CLI:
94
+
95
+ ```bash
96
+ claude mcp add promptready -- promptready-mcp
97
+ ```
98
+
99
+ ### Reconnect after changing config
100
+
101
+ Hosts do not pick up MCP config changes mid-session. After changing the
102
+ config, restart the host or reconnect the server — in Claude Code, open the
103
+ `/mcp` panel and reconnect.
104
+
105
+ Seeing tools in the `/mcp` panel does **not** mean the server is connected:
106
+ the panel can list the tool catalog while the session has no live server,
107
+ and picking a tool from that list will not run it. If tools are listed but
108
+ calls fail, reconnect first.
109
+
110
+ ### Claude Code
111
+
112
+ ```bash
113
+ claude mcp add promptready -- promptready-mcp
114
+ ```
115
+
116
+ The default scope is `local` (this project only). Use `--scope user` to
117
+ register it for all your projects, or `--scope project` to share the
118
+ registration through a committed `.mcp.json`.
119
+
120
+ ### Cursor
121
+
122
+ Add to `~/.cursor/mcp.json` (or `.cursor/mcp.json` for a single project):
123
+
124
+ ```json
125
+ {
126
+ "mcpServers": {
127
+ "promptready": {
128
+ "command": "promptready-mcp"
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ ### Grok
135
+
136
+ ```toml
137
+ [mcp_servers.promptready]
138
+ command = "promptready-mcp"
139
+ enabled = true
140
+ tool_timeout_sec = 3600
141
+ ```
142
+
143
+ ### Claude Desktop / generic JSON
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "promptready": {
149
+ "command": "promptready-mcp"
150
+ }
151
+ }
152
+ }
153
+ ```
154
+
155
+ No access token in config files required after login.
156
+
157
+ <details>
158
+ <summary>Host cannot find <code>promptready-mcp</code></summary>
159
+
160
+ GUI hosts start servers with a narrow `PATH`, so a console script installed by
161
+ `pip install --user` is often invisible to them — the host reports a spawn
162
+ failure or "server disconnected" rather than a missing command.
163
+
164
+ Two reliable fixes:
165
+
166
+ ```json
167
+ { "mcpServers": { "promptready": {
168
+ "command": "uvx", "args": ["promptready-mcp"] } } }
169
+ ```
170
+
171
+ or point at the absolute path of the script:
172
+ `/ABS/PATH/.venv/bin/promptready-mcp`.
173
+
174
+ </details>
175
+
176
+ ## Tools
177
+
178
+ | Tool | Purpose |
179
+ |------|---------|
180
+ | `login` / `logout` | Browser auth / clear local credentials |
181
+ | `get_credits` | Credit balance |
182
+ | `get_convert_settings` / `set_convert_settings` | Saved convert defaults |
183
+ | `convert_pdf` | Upload path → queue (optional `wait`) |
184
+ | `get_status` | Job status |
185
+ | `wait_and_download` | Poll + save `.md` |
186
+
187
+ Downloaded names follow the web app: `{name}_PaddleOCR-VL.md` (engine label).
188
+
189
+ Credits are deducted by the server when a job is queued, exactly as on the web
190
+ app. `convert_pdf(wait=True)` can run for a long time, so give the host a high
191
+ tool timeout.
192
+
193
+ ## Security
194
+
195
+ - Tokens are **never** hardcoded in this repository.
196
+ - Do **not** commit `~/.config/promptready/*` or `.env`.
197
+ - Only use the official package linked from https://promptready.space
198
+ - Vulnerability reports: see [SECURITY.md](SECURITY.md)
199
+
200
+ ## Service terms
201
+
202
+ Using the cloud API is subject to the
203
+ [Terms of Service](https://promptready.space/legal/terms-of-service.en.md) and
204
+ [Privacy Policy](https://promptready.space/legal/privacy-policy.en.md).
205
+ This MIT-licensed client does not grant free unlimited conversion.
206
+
207
+ ## Smoke test (no account)
208
+
209
+ ```bash
210
+ ./scripts/smoke_stdio.sh
211
+ ```
212
+
213
+ ## License
214
+
215
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ """PromptReady MCP server (stdio).
2
+
3
+ P0: get_credits
4
+ P1: convert_pdf, get_status, wait_and_download
5
+ """
6
+
7
+ __version__ = "0.3.3"
@@ -0,0 +1,166 @@
1
+ """Thin async HTTP client for the PromptReady backend API.
2
+
3
+ Source of truth:
4
+ - app/api/endpoints/users.py GET /api/v1/users/credits
5
+ - app/api/endpoints/convert.py POST /api/v1/convert, GET /api/v1/convert/status
6
+ - app/api/endpoints/auth.py POST /api/v1/auth/refresh
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ import httpx
14
+
15
+ API_PREFIX = "/api/v1"
16
+ DEFAULT_TIMEOUT = 60.0
17
+ CONVERT_TIMEOUT = 300.0 # upload + queue only; OCR runs async
18
+
19
+
20
+ class PromptReadyAPIError(Exception):
21
+ """API or transport failure with a user-facing message."""
22
+
23
+ def __init__(
24
+ self,
25
+ message: str,
26
+ *,
27
+ status_code: Optional[int] = None,
28
+ body: Optional[str] = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ self.status_code = status_code
33
+ self.body = body
34
+
35
+
36
+ def _url(base_url: str, path: str) -> str:
37
+ return f"{base_url.rstrip('/')}{API_PREFIX}{path}"
38
+
39
+
40
+ def _auth_headers(access_token: str) -> Dict[str, str]:
41
+ return {"Authorization": f"Bearer {access_token}"}
42
+
43
+
44
+ async def fetch_credits(
45
+ base_url: str, access_token: str, *, timeout: float = DEFAULT_TIMEOUT
46
+ ) -> Dict[str, Any]:
47
+ """GET /users/credits → {credit_balance, email}."""
48
+ async with httpx.AsyncClient(timeout=timeout) as http:
49
+ resp = await http.get(
50
+ _url(base_url, "/users/credits"),
51
+ headers=_auth_headers(access_token),
52
+ )
53
+ if resp.status_code >= 400:
54
+ raise PromptReadyAPIError(
55
+ f"credits failed HTTP {resp.status_code}",
56
+ status_code=resp.status_code,
57
+ body=resp.text[:500],
58
+ )
59
+ return resp.json()
60
+
61
+
62
+ async def refresh_access_token(
63
+ base_url: str, refresh_token: str, *, timeout: float = DEFAULT_TIMEOUT
64
+ ) -> Dict[str, str]:
65
+ """POST /auth/refresh → {access_token, refresh_token}."""
66
+ async with httpx.AsyncClient(timeout=timeout) as http:
67
+ resp = await http.post(
68
+ _url(base_url, "/auth/refresh"),
69
+ json={"refresh_token": refresh_token},
70
+ )
71
+ if resp.status_code >= 400:
72
+ raise PromptReadyAPIError(
73
+ f"token refresh failed HTTP {resp.status_code}",
74
+ status_code=resp.status_code,
75
+ body=resp.text[:500],
76
+ )
77
+ data = resp.json()
78
+ if not data.get("success") or not data.get("access_token"):
79
+ raise PromptReadyAPIError(
80
+ data.get("message") or "token refresh returned no access_token",
81
+ body=str(data)[:500],
82
+ )
83
+ out: Dict[str, str] = {"access_token": data["access_token"]}
84
+ if data.get("refresh_token"):
85
+ out["refresh_token"] = data["refresh_token"]
86
+ return out
87
+
88
+
89
+ async def convert_pdf(
90
+ base_url: str,
91
+ access_token: str,
92
+ file_path: Path,
93
+ *,
94
+ timeout: float = CONVERT_TIMEOUT,
95
+ params: Optional[Dict[str, str]] = None,
96
+ ) -> Dict[str, Any]:
97
+ """POST /convert multipart — queues PDF and returns ConvertResponse JSON.
98
+
99
+ Query params come from user_settings (saved defaults), not per-call UI.
100
+ """
101
+ from .user_settings import settings_to_api_params
102
+
103
+ path = Path(file_path).expanduser().resolve()
104
+ if not path.is_file():
105
+ raise PromptReadyAPIError(f"file not found: {path}")
106
+ if path.suffix.lower() not in (".pdf", ".csv"):
107
+ raise PromptReadyAPIError("only .pdf or .csv files are supported")
108
+
109
+ query = params if params is not None else settings_to_api_params()
110
+ data = path.read_bytes()
111
+ if not data:
112
+ raise PromptReadyAPIError("file is empty")
113
+
114
+ files = {
115
+ "file": (path.name, data, "application/pdf" if path.suffix.lower() == ".pdf" else "text/csv"),
116
+ }
117
+ async with httpx.AsyncClient(timeout=timeout) as http:
118
+ resp = await http.post(
119
+ _url(base_url, "/convert"),
120
+ headers=_auth_headers(access_token),
121
+ params=query,
122
+ files=files,
123
+ )
124
+ if resp.status_code >= 400:
125
+ raise PromptReadyAPIError(
126
+ f"convert failed HTTP {resp.status_code}: {resp.text[:300]}",
127
+ status_code=resp.status_code,
128
+ body=resp.text[:500],
129
+ )
130
+ return resp.json()
131
+
132
+
133
+ async def get_conversion_status(
134
+ base_url: str, access_token: str, *, timeout: float = DEFAULT_TIMEOUT
135
+ ) -> Dict[str, Any]:
136
+ """GET /convert/status for the authenticated user's session."""
137
+ async with httpx.AsyncClient(timeout=timeout) as http:
138
+ resp = await http.get(
139
+ _url(base_url, "/convert/status"),
140
+ headers=_auth_headers(access_token),
141
+ )
142
+ if resp.status_code >= 400:
143
+ raise PromptReadyAPIError(
144
+ f"status failed HTTP {resp.status_code}",
145
+ status_code=resp.status_code,
146
+ body=resp.text[:500],
147
+ )
148
+ return resp.json()
149
+
150
+
151
+ async def download_url_to_path(
152
+ url: str, dest: Path, *, timeout: float = CONVERT_TIMEOUT
153
+ ) -> Path:
154
+ """Download markdown_url (presigned) to dest path."""
155
+ dest = Path(dest).expanduser().resolve()
156
+ dest.parent.mkdir(parents=True, exist_ok=True)
157
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as http:
158
+ resp = await http.get(url)
159
+ if resp.status_code >= 400:
160
+ raise PromptReadyAPIError(
161
+ f"download failed HTTP {resp.status_code}",
162
+ status_code=resp.status_code,
163
+ body=resp.text[:300],
164
+ )
165
+ dest.write_bytes(resp.content)
166
+ return dest