buildwithtrace-sdk 0.1.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.
- buildwithtrace_sdk-0.1.0/.github/workflows/ci.yml +29 -0
- buildwithtrace_sdk-0.1.0/.github/workflows/release.yml +34 -0
- buildwithtrace_sdk-0.1.0/.github/workflows/sync-to-org.yml +24 -0
- buildwithtrace_sdk-0.1.0/.gitignore +18 -0
- buildwithtrace_sdk-0.1.0/PKG-INFO +73 -0
- buildwithtrace_sdk-0.1.0/README.md +50 -0
- buildwithtrace_sdk-0.1.0/pyproject.toml +55 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/__init__.py +26 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/api/__init__.py +181 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/api/auth.py +259 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/api/rest.py +103 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/api/sse.py +263 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/byok.py +61 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/client.py +700 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/config/__init__.py +149 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/config/credentials.py +268 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/eda_index.py +132 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/engine/__init__.py +368 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/engine/downloader.py +327 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/error_reporter.py +144 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/tools/__init__.py +0 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/tools/_concurrency.py +195 -0
- buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/tools/executor.py +963 -0
- buildwithtrace_sdk-0.1.0/tests/test_api.py +326 -0
- buildwithtrace_sdk-0.1.0/tests/test_byok.py +219 -0
- buildwithtrace_sdk-0.1.0/tests/test_concurrency.py +348 -0
- buildwithtrace_sdk-0.1.0/tests/test_config.py +174 -0
- buildwithtrace_sdk-0.1.0/tests/test_config_internals.py +345 -0
- buildwithtrace_sdk-0.1.0/tests/test_engine.py +371 -0
- buildwithtrace_sdk-0.1.0/tests/test_engine_edge_cases.py +244 -0
- buildwithtrace_sdk-0.1.0/tests/test_error_reporter.py +66 -0
- buildwithtrace_sdk-0.1.0/tests/test_sdk.py +312 -0
- buildwithtrace_sdk-0.1.0/tests/test_streaming_edge_cases.py +330 -0
- buildwithtrace_sdk-0.1.0/tests/test_tool_loop.py +163 -0
- buildwithtrace_sdk-0.1.0/tests/test_tools.py +313 -0
- buildwithtrace_sdk-0.1.0/tests/test_tools_security.py +358 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
if: github.repository_owner == 'elcruzo' || github.repository_owner == 'buildwithtrace'
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ['3.10', '3.11', '3.12']
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
|
|
22
|
+
- name: Install
|
|
23
|
+
run: pip install -e ".[dev]"
|
|
24
|
+
|
|
25
|
+
- name: Test
|
|
26
|
+
run: pytest -q
|
|
27
|
+
|
|
28
|
+
- name: Lint
|
|
29
|
+
run: ruff check src/
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishes buildwithtrace-sdk to PyPI on a version tag via OIDC Trusted
|
|
4
|
+
# Publishing — NO PyPI token stored. Requires a Trusted Publisher on PyPI for
|
|
5
|
+
# this repo + workflow (pypi.org -> your project -> Publishing, or a "pending
|
|
6
|
+
# publisher" before the first release). Tag: `git tag v0.1.0 && git push origin v0.1.0`.
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
tags:
|
|
10
|
+
- 'v*'
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write # OIDC token for PyPI Trusted Publishing
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
publish:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
environment: pypi
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: '3.12'
|
|
26
|
+
|
|
27
|
+
- name: Build
|
|
28
|
+
run: |
|
|
29
|
+
pip install build
|
|
30
|
+
python -m build
|
|
31
|
+
|
|
32
|
+
# No password/token: authenticates via the PyPI OIDC trusted publisher.
|
|
33
|
+
- name: Publish to PyPI (OIDC)
|
|
34
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Syncs elcruzo/trace-sdk-python -> buildwithtrace/sdk-python on push to main.
|
|
2
|
+
name: Sync to Organization
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
sync:
|
|
10
|
+
if: github.repository_owner == 'elcruzo'
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
with:
|
|
15
|
+
fetch-depth: 0
|
|
16
|
+
token: ${{ secrets.ORG_PUSH_TOKEN }}
|
|
17
|
+
persist-credentials: false
|
|
18
|
+
|
|
19
|
+
- name: Push to org repo
|
|
20
|
+
env:
|
|
21
|
+
ORG_PUSH_TOKEN: ${{ secrets.ORG_PUSH_TOKEN }}
|
|
22
|
+
run: |
|
|
23
|
+
git remote add org https://x-access-token:${ORG_PUSH_TOKEN}@github.com/buildwithtrace/sdk-python.git
|
|
24
|
+
git push org HEAD:main
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.eggs/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.ruff_cache/
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
.DS_Store
|
|
12
|
+
|
|
13
|
+
# Local secrets — never commit (PyPI recovery codes, tokens, etc.)
|
|
14
|
+
.secrets/
|
|
15
|
+
|
|
16
|
+
# local dev-only (not published)
|
|
17
|
+
publish.sh
|
|
18
|
+
AGENTS.md
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: buildwithtrace-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Trace Python SDK — programmatic client for the Trace AI EDA backend.
|
|
5
|
+
Project-URL: Homepage, https://buildwithtrace.com
|
|
6
|
+
Project-URL: Repository, https://github.com/buildwithtrace/sdk-python
|
|
7
|
+
Author-email: Trace <hello@buildwithtrace.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ai,eda,kicad,pcb,sdk,trace
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx-sse>=0.4.0
|
|
12
|
+
Requires-Dist: httpx>=0.27.0
|
|
13
|
+
Requires-Dist: keyring>=25.0
|
|
14
|
+
Requires-Dist: platformdirs>=4.0
|
|
15
|
+
Requires-Dist: rich>=13.0
|
|
16
|
+
Requires-Dist: tomli-w>=1.0
|
|
17
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# buildwithtrace-sdk
|
|
25
|
+
|
|
26
|
+
Python SDK for [Trace](https://buildwithtrace.com) — programmatic access to the Trace AI EDA backend (symbol/footprint generation, semantic component search, and chat/agent over your KiCad designs).
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install buildwithtrace-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from buildwithtrace_sdk import Trace
|
|
34
|
+
|
|
35
|
+
client = Trace(api_key="...") # or TRACE_API_KEY env var
|
|
36
|
+
|
|
37
|
+
sym = client.generate_symbol("LM7805 5V voltage regulator")
|
|
38
|
+
sym.save("./symbols/") # writes LM7805.kicad_sym
|
|
39
|
+
|
|
40
|
+
fp = client.generate_footprint("SOIC-8 3.9x4.9mm")
|
|
41
|
+
fp.save("./footprints/") # writes SOIC-8.kicad_mod
|
|
42
|
+
|
|
43
|
+
for r in client.search("3.3V LDO", type="symbol", limit=5):
|
|
44
|
+
print(r.name, r.library)
|
|
45
|
+
|
|
46
|
+
# Read-only Q&A about a design:
|
|
47
|
+
ans = client.ask("What ERC violations exist?", project_dir="./my-board/")
|
|
48
|
+
print(ans.text)
|
|
49
|
+
|
|
50
|
+
# Agent mode — the SDK runs the client-side tool loop locally (sandboxed):
|
|
51
|
+
res = client.chat("Add a 100nF decoupling cap on VCC", mode="agent", project_dir="./my-board/")
|
|
52
|
+
print(res.text)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Get an API key: `buildwithtrace auth token` (CLI) or buildwithtrace.com/dashboard/settings > Developer.
|
|
56
|
+
|
|
57
|
+
## Notes
|
|
58
|
+
|
|
59
|
+
- `chat(mode="agent")` / `"plan"` produce file-editing tool calls. The SDK runs a
|
|
60
|
+
standalone, client-side tool-execution loop: when you pass `project_dir`, file ops
|
|
61
|
+
(read/write/search_replace/list_dir/grep/delete) and local ERC/DRC/export run on your
|
|
62
|
+
machine with the SAME safety as the desktop/CLI (extension allowlist, project-dir
|
|
63
|
+
sandbox, symlink-safe path resolution), looping until the turn completes. Writes are
|
|
64
|
+
auto-approved (programmatic agent). Without a `project_dir`, an emitted tool call
|
|
65
|
+
raises `TraceToolExecutionError` (file ops can't be sandboxed); GUI-only tools
|
|
66
|
+
(canvas snapshots, layer toggles) are always reported as unsupported.
|
|
67
|
+
- `.trace_sch`/`.trace_pcb` writes are converted to KiCad format via the bundled
|
|
68
|
+
converter when available; if the converter isn't importable the file is still written
|
|
69
|
+
and the turn continues (conversion is reported as failed, not fatal).
|
|
70
|
+
- BYOK supported via `chat(..., llm_provider=, llm_api_key=, llm_model_id=)`.
|
|
71
|
+
|
|
72
|
+
Extracted from the `buildwithtrace` CLI repo. The CLI re-exports `Trace` for
|
|
73
|
+
backward compatibility (`from buildwithtrace import Trace`).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# buildwithtrace-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for [Trace](https://buildwithtrace.com) — programmatic access to the Trace AI EDA backend (symbol/footprint generation, semantic component search, and chat/agent over your KiCad designs).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install buildwithtrace-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from buildwithtrace_sdk import Trace
|
|
11
|
+
|
|
12
|
+
client = Trace(api_key="...") # or TRACE_API_KEY env var
|
|
13
|
+
|
|
14
|
+
sym = client.generate_symbol("LM7805 5V voltage regulator")
|
|
15
|
+
sym.save("./symbols/") # writes LM7805.kicad_sym
|
|
16
|
+
|
|
17
|
+
fp = client.generate_footprint("SOIC-8 3.9x4.9mm")
|
|
18
|
+
fp.save("./footprints/") # writes SOIC-8.kicad_mod
|
|
19
|
+
|
|
20
|
+
for r in client.search("3.3V LDO", type="symbol", limit=5):
|
|
21
|
+
print(r.name, r.library)
|
|
22
|
+
|
|
23
|
+
# Read-only Q&A about a design:
|
|
24
|
+
ans = client.ask("What ERC violations exist?", project_dir="./my-board/")
|
|
25
|
+
print(ans.text)
|
|
26
|
+
|
|
27
|
+
# Agent mode — the SDK runs the client-side tool loop locally (sandboxed):
|
|
28
|
+
res = client.chat("Add a 100nF decoupling cap on VCC", mode="agent", project_dir="./my-board/")
|
|
29
|
+
print(res.text)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Get an API key: `buildwithtrace auth token` (CLI) or buildwithtrace.com/dashboard/settings > Developer.
|
|
33
|
+
|
|
34
|
+
## Notes
|
|
35
|
+
|
|
36
|
+
- `chat(mode="agent")` / `"plan"` produce file-editing tool calls. The SDK runs a
|
|
37
|
+
standalone, client-side tool-execution loop: when you pass `project_dir`, file ops
|
|
38
|
+
(read/write/search_replace/list_dir/grep/delete) and local ERC/DRC/export run on your
|
|
39
|
+
machine with the SAME safety as the desktop/CLI (extension allowlist, project-dir
|
|
40
|
+
sandbox, symlink-safe path resolution), looping until the turn completes. Writes are
|
|
41
|
+
auto-approved (programmatic agent). Without a `project_dir`, an emitted tool call
|
|
42
|
+
raises `TraceToolExecutionError` (file ops can't be sandboxed); GUI-only tools
|
|
43
|
+
(canvas snapshots, layer toggles) are always reported as unsupported.
|
|
44
|
+
- `.trace_sch`/`.trace_pcb` writes are converted to KiCad format via the bundled
|
|
45
|
+
converter when available; if the converter isn't importable the file is still written
|
|
46
|
+
and the turn continues (conversion is reported as failed, not fatal).
|
|
47
|
+
- BYOK supported via `chat(..., llm_provider=, llm_api_key=, llm_model_id=)`.
|
|
48
|
+
|
|
49
|
+
Extracted from the `buildwithtrace` CLI repo. The CLI re-exports `Trace` for
|
|
50
|
+
backward compatibility (`from buildwithtrace import Trace`).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "buildwithtrace-sdk"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Trace Python SDK — programmatic client for the Trace AI EDA backend."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Trace", email = "hello@buildwithtrace.com" }]
|
|
13
|
+
keywords = ["trace", "eda", "pcb", "kicad", "sdk", "ai"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"httpx>=0.27.0",
|
|
16
|
+
"httpx-sse>=0.4.0",
|
|
17
|
+
"keyring>=25.0",
|
|
18
|
+
"platformdirs>=4.0",
|
|
19
|
+
"tomli-w>=1.0",
|
|
20
|
+
"tomli>=2.0; python_version < '3.11'",
|
|
21
|
+
# rich is used only by the engine binary downloader's progress UI (lazy import).
|
|
22
|
+
"rich>=13.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=8.0.0",
|
|
28
|
+
"pytest-asyncio>=0.23",
|
|
29
|
+
"ruff>=0.6.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://buildwithtrace.com"
|
|
34
|
+
Repository = "https://github.com/buildwithtrace/sdk-python"
|
|
35
|
+
|
|
36
|
+
# Single source of truth for the version: read __version__ from the package.
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
path = "src/buildwithtrace_sdk/__init__.py"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/buildwithtrace_sdk"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
asyncio_mode = "auto"
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 120
|
|
49
|
+
target-version = "py310"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "I", "N", "W"]
|
|
53
|
+
# E501 (line length) is advisory — many lines are long error/URL strings we
|
|
54
|
+
# intentionally don't wrap. All correctness rules (F*, etc.) stay on.
|
|
55
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Trace Python SDK — programmatic access to Trace AI for PCB/schematic design.
|
|
2
|
+
|
|
3
|
+
from buildwithtrace_sdk import Trace
|
|
4
|
+
client = Trace(api_key="...")
|
|
5
|
+
sym = client.generate_symbol("LM7805 5V regulator")
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from buildwithtrace_sdk.client import (
|
|
9
|
+
GenerateResult,
|
|
10
|
+
SearchResult,
|
|
11
|
+
TextResult,
|
|
12
|
+
Trace,
|
|
13
|
+
TraceError,
|
|
14
|
+
TraceToolExecutionError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Trace",
|
|
21
|
+
"GenerateResult",
|
|
22
|
+
"SearchResult",
|
|
23
|
+
"TextResult",
|
|
24
|
+
"TraceError",
|
|
25
|
+
"TraceToolExecutionError",
|
|
26
|
+
]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""HTTP API client — httpx with auth, retry, environment switching."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, AsyncIterator, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from buildwithtrace_sdk.config import get_api_base_url, get_backend_url
|
|
10
|
+
from buildwithtrace_sdk.config.credentials import get_access_token, get_refresh_token, store_tokens
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
REQUEST_TIMEOUT = 30.0
|
|
15
|
+
CONNECT_TIMEOUT = 10.0
|
|
16
|
+
STREAM_TIMEOUT = 300.0
|
|
17
|
+
MAX_RETRIES = 3
|
|
18
|
+
RETRY_BACKOFF = [1.0, 2.0, 4.0]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TraceAPIError(Exception):
|
|
22
|
+
"""Raised when the Trace API returns an error."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, status_code: int, message: str, code: str = ""):
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.message = message
|
|
27
|
+
self.code = code
|
|
28
|
+
super().__init__(f"[{status_code}] {message}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthRequiredError(TraceAPIError):
|
|
32
|
+
"""Raised when authentication is required but no token available."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
super().__init__(401, "Authentication required. Run: buildwithtrace auth login")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_headers() -> dict[str, str]:
|
|
39
|
+
"""Build request headers with auth token if available."""
|
|
40
|
+
headers = {
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
"User-Agent": "trace-cli/0.1.0",
|
|
43
|
+
}
|
|
44
|
+
token = get_access_token()
|
|
45
|
+
if token:
|
|
46
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
47
|
+
return headers
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _try_refresh_token() -> bool:
|
|
51
|
+
"""Attempt to refresh the access token. Returns True on success."""
|
|
52
|
+
refresh_token = get_refresh_token()
|
|
53
|
+
if not refresh_token:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
backend_url = get_backend_url()
|
|
57
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(REQUEST_TIMEOUT, connect=CONNECT_TIMEOUT)) as client:
|
|
58
|
+
try:
|
|
59
|
+
resp = await client.post(
|
|
60
|
+
f"{backend_url}/api/v3/auth/refresh",
|
|
61
|
+
json={"refresh_token": refresh_token},
|
|
62
|
+
)
|
|
63
|
+
if resp.status_code == 200:
|
|
64
|
+
data = resp.json()
|
|
65
|
+
store_tokens(
|
|
66
|
+
access_token=data["access_token"],
|
|
67
|
+
refresh_token=data["refresh_token"],
|
|
68
|
+
user_data=data.get("user"),
|
|
69
|
+
)
|
|
70
|
+
return True
|
|
71
|
+
except httpx.HTTPError:
|
|
72
|
+
pass
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def request(
|
|
77
|
+
method: str,
|
|
78
|
+
path: str,
|
|
79
|
+
json: Optional[dict] = None,
|
|
80
|
+
params: Optional[dict] = None,
|
|
81
|
+
require_auth: bool = True,
|
|
82
|
+
timeout: float = REQUEST_TIMEOUT,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
"""Make an API request with auth, retry, and error handling."""
|
|
85
|
+
if require_auth and not get_access_token():
|
|
86
|
+
raise AuthRequiredError()
|
|
87
|
+
|
|
88
|
+
url = f"{get_api_base_url()}{path}"
|
|
89
|
+
|
|
90
|
+
for attempt in range(MAX_RETRIES):
|
|
91
|
+
headers = _get_headers()
|
|
92
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
93
|
+
try:
|
|
94
|
+
resp = await client.request(
|
|
95
|
+
method, url, json=json, params=params, headers=headers
|
|
96
|
+
)
|
|
97
|
+
except httpx.ConnectError:
|
|
98
|
+
raise TraceAPIError(
|
|
99
|
+
0, f"Cannot connect to {get_backend_url()}. Run: buildwithtrace doctor"
|
|
100
|
+
)
|
|
101
|
+
except httpx.TimeoutException:
|
|
102
|
+
if attempt < MAX_RETRIES - 1:
|
|
103
|
+
await asyncio.sleep(RETRY_BACKOFF[attempt])
|
|
104
|
+
continue
|
|
105
|
+
raise TraceAPIError(408, "Request timed out")
|
|
106
|
+
|
|
107
|
+
if resp.status_code == 401:
|
|
108
|
+
if await _try_refresh_token():
|
|
109
|
+
continue
|
|
110
|
+
raise TraceAPIError(401, "Session expired. Run: buildwithtrace auth login")
|
|
111
|
+
|
|
112
|
+
if resp.status_code == 429:
|
|
113
|
+
if attempt < MAX_RETRIES - 1:
|
|
114
|
+
retry_after = float(resp.headers.get("Retry-After", RETRY_BACKOFF[attempt]))
|
|
115
|
+
logger.info(f"Rate limited. Retrying in {retry_after}s...")
|
|
116
|
+
await asyncio.sleep(retry_after)
|
|
117
|
+
continue
|
|
118
|
+
raise TraceAPIError(429, "Rate limited. Try again later.")
|
|
119
|
+
|
|
120
|
+
if resp.status_code == 402:
|
|
121
|
+
raise TraceAPIError(402, "Quota exceeded. Run: buildwithtrace billing upgrade")
|
|
122
|
+
|
|
123
|
+
if resp.status_code >= 500:
|
|
124
|
+
if attempt < MAX_RETRIES - 1:
|
|
125
|
+
await asyncio.sleep(RETRY_BACKOFF[attempt])
|
|
126
|
+
continue
|
|
127
|
+
raise TraceAPIError(resp.status_code, "Server error. Try again later.")
|
|
128
|
+
|
|
129
|
+
if resp.status_code >= 400:
|
|
130
|
+
ct = resp.headers.get("content-type", "")
|
|
131
|
+
if "application/json" in ct:
|
|
132
|
+
body = resp.json()
|
|
133
|
+
else:
|
|
134
|
+
body = {"detail": resp.text[:200]}
|
|
135
|
+
raise TraceAPIError(
|
|
136
|
+
resp.status_code,
|
|
137
|
+
body.get("detail", body.get("message", resp.text[:200])),
|
|
138
|
+
code=body.get("code", ""),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
ct = resp.headers.get("content-type", "")
|
|
142
|
+
if "application/json" in ct:
|
|
143
|
+
return resp.json()
|
|
144
|
+
return {"_raw": resp.text, "_status": resp.status_code}
|
|
145
|
+
|
|
146
|
+
raise TraceAPIError(0, "Max retries exceeded")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def stream_sse(
|
|
150
|
+
path: str,
|
|
151
|
+
json: Optional[dict] = None,
|
|
152
|
+
require_auth: bool = True,
|
|
153
|
+
) -> AsyncIterator[dict]:
|
|
154
|
+
"""Stream SSE events from the backend (for /chat/stream, /pcb/autoroute)."""
|
|
155
|
+
from httpx_sse import aconnect_sse
|
|
156
|
+
|
|
157
|
+
if require_auth and not get_access_token():
|
|
158
|
+
raise AuthRequiredError()
|
|
159
|
+
|
|
160
|
+
url = f"{get_api_base_url()}{path}"
|
|
161
|
+
headers = _get_headers()
|
|
162
|
+
|
|
163
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(STREAM_TIMEOUT, connect=10.0)) as client:
|
|
164
|
+
async with aconnect_sse(client, "POST", url, json=json, headers=headers) as event_source:
|
|
165
|
+
async for event in event_source.aiter_sse():
|
|
166
|
+
if event.data:
|
|
167
|
+
try:
|
|
168
|
+
import json as json_mod
|
|
169
|
+
yield {"event": event.event, "data": json_mod.loads(event.data)}
|
|
170
|
+
except (ValueError, TypeError):
|
|
171
|
+
yield {"event": event.event, "data": event.data}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def get(path: str, **kwargs) -> dict:
|
|
175
|
+
"""GET request shorthand."""
|
|
176
|
+
return await request("GET", path, **kwargs)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def post(path: str, **kwargs) -> dict:
|
|
180
|
+
"""POST request shorthand."""
|
|
181
|
+
return await request("POST", path, **kwargs)
|