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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ .env
3
+ .venv/
4
+ node_modules/
5
+ dist/
6
+ .DS_Store
7
+ supabase/.temp/
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
@@ -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