uprelic 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.
- uprelic-0.1.0/.gitignore +7 -0
- uprelic-0.1.0/PKG-INFO +58 -0
- uprelic-0.1.0/README.md +48 -0
- uprelic-0.1.0/pyproject.toml +20 -0
- uprelic-0.1.0/src/uprelic/__init__.py +12 -0
- uprelic-0.1.0/src/uprelic/client.py +132 -0
- uprelic-0.1.0/src/uprelic/errors.py +42 -0
- uprelic-0.1.0/src/uprelic/py.typed +0 -0
- uprelic-0.1.0/src/uprelic/types.py +92 -0
- uprelic-0.1.0/tests/test_client.py +94 -0
uprelic-0.1.0/.gitignore
ADDED
uprelic-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: uprelic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Uprelic API
|
|
5
|
+
Project-URL: Documentation, https://api.uprelic.com/v1/docs
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Uprelic Python SDK
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install uprelic
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Set `UPRELIC_API_KEY` (create a key at https://platform.uprelic.com), or pass `api_key=` to the client.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from uprelic import Uprelic, Noul, Choice, Score
|
|
21
|
+
|
|
22
|
+
client = Uprelic()
|
|
23
|
+
response = client.system_one(
|
|
24
|
+
model="strom-1.0.7",
|
|
25
|
+
state="I was charged twice for my subscription. Please fix this today.",
|
|
26
|
+
questions={
|
|
27
|
+
"billing": Noul("Is this message about billing?"),
|
|
28
|
+
"tone": Choice("What is the tone?", criteria={"angry": "Upset or hostile", "calm": "Neutral or polite"}),
|
|
29
|
+
"urgency": Score("How urgent is this?", criteria=["Can wait", "This week", "Today"]),
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
response.nouls["billing"].noul # 0.98
|
|
34
|
+
response.choices["tone"].choice # "angry"
|
|
35
|
+
response.scores["urgency"].score # 1.8
|
|
36
|
+
response.usage.input_tokens
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Images go in `images=` as http(s) or `data:image/...` URLs. `client.models()` lists the available models.
|
|
40
|
+
|
|
41
|
+
## Async
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from uprelic import AsyncUprelic
|
|
45
|
+
|
|
46
|
+
async with AsyncUprelic() as client:
|
|
47
|
+
response = await client.system_one(model="strom-1.0.7", state="...", questions={...})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Errors and retries
|
|
51
|
+
|
|
52
|
+
Failed requests raise `uprelic.APIError` subclasses: `AuthenticationError` (401), `InsufficientBalanceError` (402),
|
|
53
|
+
`InvalidRequestError` (400, 422), `RateLimitError` (429) and `ServerError` (5xx). Each has `status_code` and `detail`.
|
|
54
|
+
|
|
55
|
+
Rate limits (429), 502/503/504 responses and connection failures are retried twice with backoff. Change this with
|
|
56
|
+
`Uprelic(max_retries=...)`.
|
|
57
|
+
|
|
58
|
+
API reference: https://api.uprelic.com/v1/docs
|
uprelic-0.1.0/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Uprelic Python SDK
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
pip install uprelic
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Set `UPRELIC_API_KEY` (create a key at https://platform.uprelic.com), or pass `api_key=` to the client.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from uprelic import Uprelic, Noul, Choice, Score
|
|
11
|
+
|
|
12
|
+
client = Uprelic()
|
|
13
|
+
response = client.system_one(
|
|
14
|
+
model="strom-1.0.7",
|
|
15
|
+
state="I was charged twice for my subscription. Please fix this today.",
|
|
16
|
+
questions={
|
|
17
|
+
"billing": Noul("Is this message about billing?"),
|
|
18
|
+
"tone": Choice("What is the tone?", criteria={"angry": "Upset or hostile", "calm": "Neutral or polite"}),
|
|
19
|
+
"urgency": Score("How urgent is this?", criteria=["Can wait", "This week", "Today"]),
|
|
20
|
+
},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
response.nouls["billing"].noul # 0.98
|
|
24
|
+
response.choices["tone"].choice # "angry"
|
|
25
|
+
response.scores["urgency"].score # 1.8
|
|
26
|
+
response.usage.input_tokens
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Images go in `images=` as http(s) or `data:image/...` URLs. `client.models()` lists the available models.
|
|
30
|
+
|
|
31
|
+
## Async
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from uprelic import AsyncUprelic
|
|
35
|
+
|
|
36
|
+
async with AsyncUprelic() as client:
|
|
37
|
+
response = await client.system_one(model="strom-1.0.7", state="...", questions={...})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Errors and retries
|
|
41
|
+
|
|
42
|
+
Failed requests raise `uprelic.APIError` subclasses: `AuthenticationError` (401), `InsufficientBalanceError` (402),
|
|
43
|
+
`InvalidRequestError` (400, 422), `RateLimitError` (429) and `ServerError` (5xx). Each has `status_code` and `detail`.
|
|
44
|
+
|
|
45
|
+
Rate limits (429), 502/503/504 responses and connection failures are retried twice with backoff. Change this with
|
|
46
|
+
`Uprelic(max_retries=...)`.
|
|
47
|
+
|
|
48
|
+
API reference: https://api.uprelic.com/v1/docs
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uprelic"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python client for the Uprelic API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["httpx>=0.27", "pydantic>=2"]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Documentation = "https://api.uprelic.com/v1/docs"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.version]
|
|
17
|
+
path = "src/uprelic/__init__.py"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.sdist]
|
|
20
|
+
include = ["src", "tests", "README.md"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .client import AsyncUprelic, Uprelic
|
|
4
|
+
from .errors import (
|
|
5
|
+
APIError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
InsufficientBalanceError,
|
|
8
|
+
InvalidRequestError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
ServerError,
|
|
11
|
+
)
|
|
12
|
+
from .types import Choice, ChoiceAnswer, Model, Noul, NoulAnswer, Score, ScoreAnswer, SystemOneResponse, Usage
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .errors import error_for
|
|
9
|
+
from .types import Model, ModelList, Question, SystemOneResponse, question_dict
|
|
10
|
+
|
|
11
|
+
BASE_URL = "https://api.uprelic.com/v1"
|
|
12
|
+
TIMEOUT = 150
|
|
13
|
+
RETRY_STATUSES = {429, 502, 503, 504}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Base:
|
|
17
|
+
def __init__(self, api_key, base_url, timeout, max_retries):
|
|
18
|
+
api_key = api_key or os.environ.get("UPRELIC_API_KEY")
|
|
19
|
+
self.base_url = (base_url or os.environ.get("UPRELIC_BASE_URL") or BASE_URL).rstrip("/")
|
|
20
|
+
self.timeout = timeout
|
|
21
|
+
self.max_retries = max_retries
|
|
22
|
+
self.headers = {"User-Agent": f"uprelic-python/{__version__}"}
|
|
23
|
+
if api_key:
|
|
24
|
+
self.headers["Authorization"] = f"Bearer {api_key}"
|
|
25
|
+
|
|
26
|
+
def _retry_delay(self, attempt, response):
|
|
27
|
+
if attempt >= self.max_retries:
|
|
28
|
+
return None
|
|
29
|
+
if response is None or response.status_code in RETRY_STATUSES:
|
|
30
|
+
retry_after = response is not None and response.headers.get("Retry-After")
|
|
31
|
+
return min(float(retry_after), 30) if retry_after else 0.5 * 2 ** attempt
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _body(model, state, questions, images):
|
|
36
|
+
body = {"model": model, "state": state,
|
|
37
|
+
"questions": {name: question_dict(question) for name, question in questions.items()}}
|
|
38
|
+
if images:
|
|
39
|
+
body["media"] = [{"type": "image", "url": url} for url in images]
|
|
40
|
+
return body
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse(response):
|
|
44
|
+
if response.is_success:
|
|
45
|
+
return response.json()
|
|
46
|
+
try:
|
|
47
|
+
detail = response.json().get("detail")
|
|
48
|
+
except (ValueError, AttributeError):
|
|
49
|
+
detail = response.text
|
|
50
|
+
raise error_for(response.status_code, detail)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Uprelic(_Base):
|
|
54
|
+
def __init__(self, api_key=None, *, base_url=None, timeout=TIMEOUT, max_retries=2, http_client=None):
|
|
55
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
56
|
+
self._owns_client = http_client is None
|
|
57
|
+
self._http = http_client or httpx.Client()
|
|
58
|
+
|
|
59
|
+
def system_one(self, *, model: str, state, questions: dict[str, Question],
|
|
60
|
+
images: list[str] | None = None) -> SystemOneResponse:
|
|
61
|
+
body = _body(model, state, questions, images)
|
|
62
|
+
return SystemOneResponse.model_validate(self._request("POST", "/systemone", body))
|
|
63
|
+
|
|
64
|
+
def models(self) -> list[Model]:
|
|
65
|
+
return ModelList.model_validate(self._request("GET", "/models")).models
|
|
66
|
+
|
|
67
|
+
def _request(self, method, path, body=None):
|
|
68
|
+
attempt = 0
|
|
69
|
+
while True:
|
|
70
|
+
response = None
|
|
71
|
+
try:
|
|
72
|
+
response = self._http.request(method, self.base_url + path, json=body, headers=self.headers,
|
|
73
|
+
timeout=self.timeout)
|
|
74
|
+
except httpx.ConnectError:
|
|
75
|
+
if self._retry_delay(attempt, None) is None:
|
|
76
|
+
raise
|
|
77
|
+
delay = self._retry_delay(attempt, response)
|
|
78
|
+
if response is not None and delay is None:
|
|
79
|
+
return _parse(response)
|
|
80
|
+
time.sleep(delay)
|
|
81
|
+
attempt += 1
|
|
82
|
+
|
|
83
|
+
def close(self):
|
|
84
|
+
if self._owns_client:
|
|
85
|
+
self._http.close()
|
|
86
|
+
|
|
87
|
+
def __enter__(self):
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(self, *exc):
|
|
91
|
+
self.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AsyncUprelic(_Base):
|
|
95
|
+
def __init__(self, api_key=None, *, base_url=None, timeout=TIMEOUT, max_retries=2, http_client=None):
|
|
96
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
97
|
+
self._owns_client = http_client is None
|
|
98
|
+
self._http = http_client or httpx.AsyncClient()
|
|
99
|
+
|
|
100
|
+
async def system_one(self, *, model: str, state, questions: dict[str, Question],
|
|
101
|
+
images: list[str] | None = None) -> SystemOneResponse:
|
|
102
|
+
body = _body(model, state, questions, images)
|
|
103
|
+
return SystemOneResponse.model_validate(await self._request("POST", "/systemone", body))
|
|
104
|
+
|
|
105
|
+
async def models(self) -> list[Model]:
|
|
106
|
+
return ModelList.model_validate(await self._request("GET", "/models")).models
|
|
107
|
+
|
|
108
|
+
async def _request(self, method, path, body=None):
|
|
109
|
+
attempt = 0
|
|
110
|
+
while True:
|
|
111
|
+
response = None
|
|
112
|
+
try:
|
|
113
|
+
response = await self._http.request(method, self.base_url + path, json=body, headers=self.headers,
|
|
114
|
+
timeout=self.timeout)
|
|
115
|
+
except httpx.ConnectError:
|
|
116
|
+
if self._retry_delay(attempt, None) is None:
|
|
117
|
+
raise
|
|
118
|
+
delay = self._retry_delay(attempt, response)
|
|
119
|
+
if response is not None and delay is None:
|
|
120
|
+
return _parse(response)
|
|
121
|
+
await asyncio.sleep(delay)
|
|
122
|
+
attempt += 1
|
|
123
|
+
|
|
124
|
+
async def close(self):
|
|
125
|
+
if self._owns_client:
|
|
126
|
+
await self._http.aclose()
|
|
127
|
+
|
|
128
|
+
async def __aenter__(self):
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
async def __aexit__(self, *exc):
|
|
132
|
+
await self.close()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
class APIError(Exception):
|
|
2
|
+
def __init__(self, status_code, detail):
|
|
3
|
+
self.status_code = status_code
|
|
4
|
+
self.detail = detail
|
|
5
|
+
super().__init__(f"HTTP {status_code}: {_message(detail)}")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthenticationError(APIError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InsufficientBalanceError(APIError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidRequestError(APIError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimitError(APIError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ServerError(APIError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ERRORS = {400: InvalidRequestError, 401: AuthenticationError, 402: InsufficientBalanceError,
|
|
29
|
+
422: InvalidRequestError, 429: RateLimitError}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def error_for(status_code, detail):
|
|
33
|
+
cls = ERRORS.get(status_code, ServerError if status_code >= 500 else APIError)
|
|
34
|
+
return cls(status_code, detail)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# A 422's detail is a list of {loc, msg}; show each as "questions.tone.criteria: msg".
|
|
38
|
+
def _message(detail):
|
|
39
|
+
if isinstance(detail, list):
|
|
40
|
+
return "; ".join(".".join(str(part) for part in error.get("loc", [])[1:]) + ": " + error.get("msg", "")
|
|
41
|
+
for error in detail if isinstance(error, dict))
|
|
42
|
+
return str(detail)
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from dataclasses import asdict, dataclass, field
|
|
2
|
+
from typing import Annotated, Any, ClassVar, Literal, Union
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
Json = Union[str, dict[str, Any], list[Any], None]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Noul:
|
|
11
|
+
instructions: Json = None
|
|
12
|
+
criteria: dict[str, Json] | None = None # {"true": ..., "false": ...}
|
|
13
|
+
type: ClassVar[str] = "noul"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Choice:
|
|
18
|
+
instructions: Json = None
|
|
19
|
+
criteria: dict[str, Json] = field(kw_only=True)
|
|
20
|
+
type: ClassVar[str] = "choice"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Score:
|
|
25
|
+
instructions: Json = None
|
|
26
|
+
criteria: list[Json] = field(kw_only=True)
|
|
27
|
+
type: ClassVar[str] = "score"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Question = Union[Noul, Choice, Score, dict[str, Any]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def question_dict(question):
|
|
34
|
+
if isinstance(question, dict):
|
|
35
|
+
return question
|
|
36
|
+
return {"type": question.type, **{k: v for k, v in asdict(question).items() if v is not None}}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NoulAnswer(BaseModel):
|
|
40
|
+
type: Literal["noul"]
|
|
41
|
+
noul: float
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ChoiceAnswer(BaseModel):
|
|
45
|
+
type: Literal["choice"]
|
|
46
|
+
choice: str
|
|
47
|
+
confidence: float
|
|
48
|
+
probabilities: dict[str, float]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ScoreAnswer(BaseModel):
|
|
52
|
+
type: Literal["score"]
|
|
53
|
+
score: float
|
|
54
|
+
confidence: float
|
|
55
|
+
legend: dict[str, Json]
|
|
56
|
+
probabilities: dict[str, float]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
Answer = Annotated[Union[NoulAnswer, ChoiceAnswer, ScoreAnswer], Field(discriminator="type")]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Usage(BaseModel):
|
|
63
|
+
input_tokens: int
|
|
64
|
+
output_tokens: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SystemOneResponse(BaseModel):
|
|
68
|
+
model: str
|
|
69
|
+
answers: dict[str, Answer]
|
|
70
|
+
usage: Usage
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def nouls(self) -> dict[str, NoulAnswer]:
|
|
74
|
+
return {name: a for name, a in self.answers.items() if isinstance(a, NoulAnswer)}
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def choices(self) -> dict[str, ChoiceAnswer]:
|
|
78
|
+
return {name: a for name, a in self.answers.items() if isinstance(a, ChoiceAnswer)}
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def scores(self) -> dict[str, ScoreAnswer]:
|
|
82
|
+
return {name: a for name, a in self.answers.items() if isinstance(a, ScoreAnswer)}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Model(BaseModel):
|
|
86
|
+
name: str
|
|
87
|
+
description: str
|
|
88
|
+
release_date: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ModelList(BaseModel):
|
|
92
|
+
models: list[Model]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import uprelic
|
|
8
|
+
from uprelic import AsyncUprelic, Choice, Noul, Score, Uprelic
|
|
9
|
+
|
|
10
|
+
RESPONSE = {
|
|
11
|
+
"model": "strom-1.0.7",
|
|
12
|
+
"answers": {
|
|
13
|
+
"billing": {"type": "noul", "noul": 0.98},
|
|
14
|
+
"tone": {"type": "choice", "choice": "angry", "confidence": 0.9,
|
|
15
|
+
"probabilities": {"angry": 0.9, "calm": 0.1}},
|
|
16
|
+
"urgency": {"type": "score", "score": 1.7, "confidence": 0.8, "legend": {"0": "Later", "1": "Soon", "2": "Now"},
|
|
17
|
+
"probabilities": {"0": 0.1, "1": 0.1, "2": 0.8}},
|
|
18
|
+
},
|
|
19
|
+
"usage": {"input_tokens": 120, "output_tokens": 3},
|
|
20
|
+
}
|
|
21
|
+
QUESTIONS = {
|
|
22
|
+
"billing": Noul("Is this about billing?"),
|
|
23
|
+
"tone": Choice("What is the tone?", criteria={"angry": "Upset", "calm": None}),
|
|
24
|
+
"urgency": Score(criteria=["Later", "Soon", "Now"]),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def client(handler, **kwargs):
|
|
29
|
+
return Uprelic("key", http_client=httpx.Client(transport=httpx.MockTransport(handler)), **kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_system_one():
|
|
33
|
+
requests = []
|
|
34
|
+
|
|
35
|
+
def handler(request):
|
|
36
|
+
requests.append(request)
|
|
37
|
+
return httpx.Response(200, json=RESPONSE)
|
|
38
|
+
|
|
39
|
+
response = client(handler).system_one(model="strom-1.0.7", state="I was charged twice.", questions=QUESTIONS,
|
|
40
|
+
images=["https://example.com/a.jpg"])
|
|
41
|
+
|
|
42
|
+
request = requests[0]
|
|
43
|
+
assert request.url == "https://api.uprelic.com/v1/systemone"
|
|
44
|
+
assert request.headers["Authorization"] == "Bearer key"
|
|
45
|
+
assert json.loads(request.content) == {
|
|
46
|
+
"model": "strom-1.0.7",
|
|
47
|
+
"state": "I was charged twice.",
|
|
48
|
+
"questions": {
|
|
49
|
+
"billing": {"type": "noul", "instructions": "Is this about billing?"},
|
|
50
|
+
"tone": {"type": "choice", "instructions": "What is the tone?", "criteria": {"angry": "Upset", "calm": None}},
|
|
51
|
+
"urgency": {"type": "score", "criteria": ["Later", "Soon", "Now"]},
|
|
52
|
+
},
|
|
53
|
+
"media": [{"type": "image", "url": "https://example.com/a.jpg"}],
|
|
54
|
+
}
|
|
55
|
+
assert response.nouls["billing"].noul == 0.98
|
|
56
|
+
assert response.choices["tone"].choice == "angry"
|
|
57
|
+
assert response.scores["urgency"].score == 1.7
|
|
58
|
+
assert response.usage.input_tokens == 120
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_models():
|
|
62
|
+
body = {"models": [{"name": "strom-1.0.7", "description": "General.", "release_date": "2026-09-15"}]}
|
|
63
|
+
models = client(lambda request: httpx.Response(200, json=body)).models()
|
|
64
|
+
assert models[0].name == "strom-1.0.7"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_errors():
|
|
68
|
+
detail = [{"loc": ["body", "questions", "tone", "criteria"], "msg": "Provide at least one choice.",
|
|
69
|
+
"type": "value_error"}]
|
|
70
|
+
with pytest.raises(uprelic.InvalidRequestError, match="questions.tone.criteria: Provide at least one choice."):
|
|
71
|
+
client(lambda request: httpx.Response(422, json={"detail": detail})).models()
|
|
72
|
+
with pytest.raises(uprelic.InsufficientBalanceError) as error:
|
|
73
|
+
client(lambda request: httpx.Response(402, json={"detail": "Balance too low."})).models()
|
|
74
|
+
assert error.value.status_code == 402
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_retries():
|
|
78
|
+
responses = iter([httpx.Response(429, headers={"Retry-After": "0"}, json={"detail": "Slow down."}),
|
|
79
|
+
httpx.Response(503, text="Unavailable"),
|
|
80
|
+
httpx.Response(200, json={"models": []})])
|
|
81
|
+
assert client(lambda request: next(responses), max_retries=2).models() == []
|
|
82
|
+
|
|
83
|
+
with pytest.raises(uprelic.RateLimitError):
|
|
84
|
+
client(lambda request: httpx.Response(429, headers={"Retry-After": "0"}, json={"detail": "Slow down."}),
|
|
85
|
+
max_retries=1).models()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_async():
|
|
89
|
+
async def run():
|
|
90
|
+
transport = httpx.MockTransport(lambda request: httpx.Response(200, json=RESPONSE))
|
|
91
|
+
async with AsyncUprelic("key", http_client=httpx.AsyncClient(transport=transport)) as client:
|
|
92
|
+
return await client.system_one(model="strom-1.0.7", state="Hi", questions=QUESTIONS)
|
|
93
|
+
|
|
94
|
+
assert asyncio.run(run()).nouls["billing"].noul == 0.98
|