runapi-core 0.2.4__tar.gz → 0.3.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.
- {runapi_core-0.2.4 → runapi_core-0.3.0}/PKG-INFO +33 -4
- {runapi_core-0.2.4 → runapi_core-0.3.0}/README.md +31 -2
- {runapi_core-0.2.4 → runapi_core-0.3.0}/pyproject.toml +1 -1
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/__init__.py +31 -2
- runapi_core-0.3.0/src/runapi/core/account.py +42 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/auth.py +6 -1
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/contract_gen.py +9 -1
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/files.py +4 -1
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/http_client.py +10 -5
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/models.py +20 -0
- runapi_core-0.3.0/src/runapi/core/pricing.py +92 -0
- runapi_core-0.3.0/src/runapi/core/provider_client.py +24 -0
- runapi_core-0.3.0/src/runapi/core/version.py +1 -0
- runapi_core-0.3.0/tests/test_account.py +39 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_auth.py +4 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_files.py +8 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_http_client.py +13 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_models.py +14 -1
- runapi_core-0.3.0/tests/test_pricing.py +119 -0
- runapi_core-0.2.4/src/runapi/core/version.py +0 -1
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/config.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/constants.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/errors.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/multipart.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/options.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/polling.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/py.typed +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/resource.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/response.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_config.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_errors.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_polling.py +0 -0
- {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_resource.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runapi-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: RunAPI core SDK for JavaScript, Python, Ruby, Go, Java, and PHP
|
|
5
5
|
Project-URL: Homepage, https://runapi.ai/models
|
|
6
6
|
Project-URL: Documentation, https://runapi.ai/docs#runapi-sdks
|
|
7
7
|
Project-URL: Source, https://github.com/runapi-ai/core-sdk
|
|
8
8
|
Project-URL: Issues, https://github.com/runapi-ai/core-sdk/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md
|
|
10
|
-
Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.
|
|
10
|
+
Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.3.0
|
|
11
11
|
Author-email: RunAPI <contact@runapi.ai>
|
|
12
12
|
License-Expression: Apache-2.0
|
|
13
13
|
Keywords: api,core,golang,gradle,java,maven,pypi,python,ruby,runapi,runapi-ai,sdk,typescript
|
|
@@ -27,7 +27,7 @@ pip install runapi-core
|
|
|
27
27
|
|
|
28
28
|
## Notes
|
|
29
29
|
|
|
30
|
-
Use the core package for common client options, error classes, request helpers, file uploads, and task polling behavior that Provider Clients share. Configure it globally or per client:
|
|
30
|
+
Use the core package for common client options, error classes, request helpers, file uploads, live pricing, account resources, and task polling behavior that Provider Clients share. Configure it globally or per client:
|
|
31
31
|
|
|
32
32
|
```python
|
|
33
33
|
import runapi.core as runapi
|
|
@@ -39,6 +39,8 @@ runapi.configure(api_key="sk-...") # or set RUNAPI_API_KEY in the environment
|
|
|
39
39
|
|
|
40
40
|
RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use printable ASCII values up to 512 characters. Accepted values are echoed in the response and stored with the RunAPI task for support and reconciliation.
|
|
41
41
|
|
|
42
|
+
Task-creation calls also accept an optional opaque `Idempotency-Key` up to 512 characters. Generate one value per logical task and reuse it only with identical input after an unknown result. Reusing the value with different input returns `409 Conflict`; do not derive it from `X-Client-Request-Id`.
|
|
43
|
+
|
|
42
44
|
High-level Python Provider Client resource methods accept per-request options and keep response headers on the returned model object. This example uses the Suno Provider Client; install `runapi-suno` to run it.
|
|
43
45
|
|
|
44
46
|
```python
|
|
@@ -49,7 +51,10 @@ from runapi.suno import SunoClient
|
|
|
49
51
|
|
|
50
52
|
client = SunoClient(api_key=os.environ["RUNAPI_API_KEY"])
|
|
51
53
|
options = RequestOptions(
|
|
52
|
-
headers={
|
|
54
|
+
headers={
|
|
55
|
+
"X-Client-Request-Id": "order-123",
|
|
56
|
+
"Idempotency-Key": "opaque-logical-task-123",
|
|
57
|
+
},
|
|
53
58
|
)
|
|
54
59
|
|
|
55
60
|
response = client.text_to_music.create(
|
|
@@ -76,6 +81,30 @@ print(uploaded.url)
|
|
|
76
81
|
|
|
77
82
|
Public SDK docs live at https://runapi.ai/docs#runapi-sdks and the model catalog lives at https://runapi.ai/models.
|
|
78
83
|
|
|
84
|
+
## Universal resources
|
|
85
|
+
|
|
86
|
+
Every Provider Client exposes `files`, `account`, and `pricing` through one shared HTTP client. `pricing.list_schedules()` reads the current schedule, and `pricing.create_quote()` estimates a reservation without creating a Task. The schedule and ordinary quote calls do not require an API key; a quote that uses an existing Task as pricing context requires the key for that Task's Account.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from runapi.core import RequestOptions
|
|
90
|
+
from runapi.flux import FluxClient
|
|
91
|
+
|
|
92
|
+
client = FluxClient(api_key="sk-...")
|
|
93
|
+
schedules = client.pricing.list_schedules(
|
|
94
|
+
service="flux",
|
|
95
|
+
action="text_to_image",
|
|
96
|
+
options=RequestOptions(headers={"If-None-Match": '"previous-etag"'}),
|
|
97
|
+
)
|
|
98
|
+
quote = client.pricing.create_quote(
|
|
99
|
+
service="flux",
|
|
100
|
+
action="text_to_image",
|
|
101
|
+
model="flux-pro",
|
|
102
|
+
params={"prompt": "A glass observatory"},
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Task responses expose persisted billing facts at `response.billing`: `reservation`, `settlement`, and `refund` are typed objects when recorded and `None` when the historical fact is absent. These facts describe that Task and are not recalculated from the current schedule.
|
|
107
|
+
|
|
79
108
|
## License
|
|
80
109
|
|
|
81
110
|
Licensed under the Apache License, Version 2.0.
|
|
@@ -10,7 +10,7 @@ pip install runapi-core
|
|
|
10
10
|
|
|
11
11
|
## Notes
|
|
12
12
|
|
|
13
|
-
Use the core package for common client options, error classes, request helpers, file uploads, and task polling behavior that Provider Clients share. Configure it globally or per client:
|
|
13
|
+
Use the core package for common client options, error classes, request helpers, file uploads, live pricing, account resources, and task polling behavior that Provider Clients share. Configure it globally or per client:
|
|
14
14
|
|
|
15
15
|
```python
|
|
16
16
|
import runapi.core as runapi
|
|
@@ -22,6 +22,8 @@ runapi.configure(api_key="sk-...") # or set RUNAPI_API_KEY in the environment
|
|
|
22
22
|
|
|
23
23
|
RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use printable ASCII values up to 512 characters. Accepted values are echoed in the response and stored with the RunAPI task for support and reconciliation.
|
|
24
24
|
|
|
25
|
+
Task-creation calls also accept an optional opaque `Idempotency-Key` up to 512 characters. Generate one value per logical task and reuse it only with identical input after an unknown result. Reusing the value with different input returns `409 Conflict`; do not derive it from `X-Client-Request-Id`.
|
|
26
|
+
|
|
25
27
|
High-level Python Provider Client resource methods accept per-request options and keep response headers on the returned model object. This example uses the Suno Provider Client; install `runapi-suno` to run it.
|
|
26
28
|
|
|
27
29
|
```python
|
|
@@ -32,7 +34,10 @@ from runapi.suno import SunoClient
|
|
|
32
34
|
|
|
33
35
|
client = SunoClient(api_key=os.environ["RUNAPI_API_KEY"])
|
|
34
36
|
options = RequestOptions(
|
|
35
|
-
headers={
|
|
37
|
+
headers={
|
|
38
|
+
"X-Client-Request-Id": "order-123",
|
|
39
|
+
"Idempotency-Key": "opaque-logical-task-123",
|
|
40
|
+
},
|
|
36
41
|
)
|
|
37
42
|
|
|
38
43
|
response = client.text_to_music.create(
|
|
@@ -59,6 +64,30 @@ print(uploaded.url)
|
|
|
59
64
|
|
|
60
65
|
Public SDK docs live at https://runapi.ai/docs#runapi-sdks and the model catalog lives at https://runapi.ai/models.
|
|
61
66
|
|
|
67
|
+
## Universal resources
|
|
68
|
+
|
|
69
|
+
Every Provider Client exposes `files`, `account`, and `pricing` through one shared HTTP client. `pricing.list_schedules()` reads the current schedule, and `pricing.create_quote()` estimates a reservation without creating a Task. The schedule and ordinary quote calls do not require an API key; a quote that uses an existing Task as pricing context requires the key for that Task's Account.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from runapi.core import RequestOptions
|
|
73
|
+
from runapi.flux import FluxClient
|
|
74
|
+
|
|
75
|
+
client = FluxClient(api_key="sk-...")
|
|
76
|
+
schedules = client.pricing.list_schedules(
|
|
77
|
+
service="flux",
|
|
78
|
+
action="text_to_image",
|
|
79
|
+
options=RequestOptions(headers={"If-None-Match": '"previous-etag"'}),
|
|
80
|
+
)
|
|
81
|
+
quote = client.pricing.create_quote(
|
|
82
|
+
service="flux",
|
|
83
|
+
action="text_to_image",
|
|
84
|
+
model="flux-pro",
|
|
85
|
+
params={"prompt": "A glass observatory"},
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Task responses expose persisted billing facts at `response.billing`: `reservation`, `settlement`, and `refund` are typed objects when recorded and `None` when the historical fact is absent. These facts describe that Task and are not recalculated from the current schedule.
|
|
90
|
+
|
|
62
91
|
## License
|
|
63
92
|
|
|
64
93
|
Licensed under the Apache License, Version 2.0.
|
|
@@ -19,7 +19,7 @@ Documentation = "https://runapi.ai/docs#runapi-sdks"
|
|
|
19
19
|
Source = "https://github.com/runapi-ai/core-sdk"
|
|
20
20
|
Issues = "https://github.com/runapi-ai/core-sdk/issues"
|
|
21
21
|
Changelog = "https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md"
|
|
22
|
-
"Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.
|
|
22
|
+
"Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.3.0"
|
|
23
23
|
|
|
24
24
|
[tool.hatch.version]
|
|
25
25
|
path = "src/runapi/core/version.py"
|
|
@@ -5,7 +5,8 @@ client shared by every per-model-line package.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from . import config, errors, polling
|
|
8
|
-
from .auth import resolve_api_key
|
|
8
|
+
from .auth import resolve_api_key, resolve_optional_api_key
|
|
9
|
+
from .account import Account, AccountBalanceResponse, AccountInfoResponse, AccountRecord
|
|
9
10
|
from .config import configure
|
|
10
11
|
from .contract_gen import CONTRACT
|
|
11
12
|
from .errors import (
|
|
@@ -26,11 +27,23 @@ from .errors import (
|
|
|
26
27
|
)
|
|
27
28
|
from .files import FilesClient, UploadResponse
|
|
28
29
|
from .http_client import HttpClient
|
|
29
|
-
from .models import
|
|
30
|
+
from .models import (
|
|
31
|
+
BaseModel,
|
|
32
|
+
BillingRefund,
|
|
33
|
+
BillingReservation,
|
|
34
|
+
BillingSettlement,
|
|
35
|
+
DynamicModel,
|
|
36
|
+
TaskBillingFacts,
|
|
37
|
+
TaskResponse,
|
|
38
|
+
optional,
|
|
39
|
+
required,
|
|
40
|
+
)
|
|
30
41
|
from .multipart import MultipartBody, MultipartFile
|
|
31
42
|
from .options import ClientOptions, PollingOptions, RequestOptions
|
|
32
43
|
from .response import ApiResponse, ResponseHeaders
|
|
33
44
|
from .resource import Resource
|
|
45
|
+
from .pricing import PriceQuote, PriceQuoteResponse, PriceSchedule, PriceScheduleListResponse, Pricing, PricingClient
|
|
46
|
+
from .provider_client import ProviderClient
|
|
34
47
|
from .version import __version__
|
|
35
48
|
|
|
36
49
|
__all__ = [
|
|
@@ -41,6 +54,11 @@ __all__ = [
|
|
|
41
54
|
"polling",
|
|
42
55
|
"CONTRACT",
|
|
43
56
|
"resolve_api_key",
|
|
57
|
+
"resolve_optional_api_key",
|
|
58
|
+
"Account",
|
|
59
|
+
"AccountRecord",
|
|
60
|
+
"AccountInfoResponse",
|
|
61
|
+
"AccountBalanceResponse",
|
|
44
62
|
"Error",
|
|
45
63
|
"AuthenticationError",
|
|
46
64
|
"InsufficientCreditsError",
|
|
@@ -63,6 +81,10 @@ __all__ = [
|
|
|
63
81
|
"BaseModel",
|
|
64
82
|
"DynamicModel",
|
|
65
83
|
"TaskResponse",
|
|
84
|
+
"BillingReservation",
|
|
85
|
+
"BillingSettlement",
|
|
86
|
+
"BillingRefund",
|
|
87
|
+
"TaskBillingFacts",
|
|
66
88
|
"required",
|
|
67
89
|
"optional",
|
|
68
90
|
"MultipartBody",
|
|
@@ -71,4 +93,11 @@ __all__ = [
|
|
|
71
93
|
"Resource",
|
|
72
94
|
"FilesClient",
|
|
73
95
|
"UploadResponse",
|
|
96
|
+
"Pricing",
|
|
97
|
+
"PricingClient",
|
|
98
|
+
"PriceSchedule",
|
|
99
|
+
"PriceScheduleListResponse",
|
|
100
|
+
"PriceQuote",
|
|
101
|
+
"PriceQuoteResponse",
|
|
102
|
+
"ProviderClient",
|
|
74
103
|
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Account resources available from every Provider Client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .models import BaseModel, required
|
|
8
|
+
from .options import RequestOptions
|
|
9
|
+
from .resource import Resource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AccountRecord(BaseModel):
|
|
13
|
+
id = required(int)
|
|
14
|
+
name = required(str)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AccountInfoResponse(BaseModel):
|
|
18
|
+
id = required(int)
|
|
19
|
+
name = required(str)
|
|
20
|
+
email = required(str)
|
|
21
|
+
account = required(AccountRecord)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AccountBalanceResponse(BaseModel):
|
|
25
|
+
balance_cents = required(int)
|
|
26
|
+
paid_balance_cents = required(int)
|
|
27
|
+
bonus_balance_cents = required(int)
|
|
28
|
+
spent_cents_today = required(int)
|
|
29
|
+
spent_cents_total = required(int)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Account(Resource):
|
|
33
|
+
"""Read the authenticated user's account information and balance."""
|
|
34
|
+
|
|
35
|
+
INFO_ENDPOINT = "/api/v1/me"
|
|
36
|
+
BALANCE_ENDPOINT = "/api/v1/me/balance"
|
|
37
|
+
|
|
38
|
+
def info(self, options: Optional[RequestOptions] = None) -> AccountInfoResponse:
|
|
39
|
+
return self._request("get", self.INFO_ENDPOINT, options=options, response_class=AccountInfoResponse)
|
|
40
|
+
|
|
41
|
+
def balance(self, options: Optional[RequestOptions] = None) -> AccountBalanceResponse:
|
|
42
|
+
return self._request("get", self.BALANCE_ENDPOINT, options=options, response_class=AccountBalanceResponse)
|
|
@@ -23,12 +23,17 @@ def resolve_api_key(explicit: Optional[str] = None) -> str:
|
|
|
23
23
|
All sources are trimmed; blank values are treated as missing. Raises
|
|
24
24
|
:class:`AuthenticationError` when no source yields a value.
|
|
25
25
|
"""
|
|
26
|
-
resolved =
|
|
26
|
+
resolved = resolve_optional_api_key(explicit)
|
|
27
27
|
if not resolved:
|
|
28
28
|
raise AuthenticationError(MISSING_KEY_MESSAGE)
|
|
29
29
|
return resolved
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def resolve_optional_api_key(explicit: Optional[str] = None) -> Optional[str]:
|
|
33
|
+
"""Resolve an API key without requiring one for public resources."""
|
|
34
|
+
return _normalize(explicit) or _normalize(config.api_key) or _normalize(os.environ.get(ENV_VAR_NAME))
|
|
35
|
+
|
|
36
|
+
|
|
32
37
|
def _normalize(value: Optional[str]) -> Optional[str]:
|
|
33
38
|
if not isinstance(value, str):
|
|
34
39
|
return None
|
|
@@ -703,7 +703,7 @@ CONTRACT = {
|
|
|
703
703
|
}
|
|
704
704
|
},
|
|
705
705
|
"kling/motion-control": {
|
|
706
|
-
"models": ["kling-3.0"],
|
|
706
|
+
"models": ["kling-3.0", "kling-v2.6"],
|
|
707
707
|
"fields_by_model": {
|
|
708
708
|
"kling-3.0": {
|
|
709
709
|
"background_source": {
|
|
@@ -715,6 +715,14 @@ CONTRACT = {
|
|
|
715
715
|
"output_resolution": {
|
|
716
716
|
"enum": ["720p", "1080p"]
|
|
717
717
|
}
|
|
718
|
+
},
|
|
719
|
+
"kling-v2.6": {
|
|
720
|
+
"character_orientation": {
|
|
721
|
+
"enum": ["video", "image"]
|
|
722
|
+
},
|
|
723
|
+
"output_resolution": {
|
|
724
|
+
"enum": ["720p", "1080p"]
|
|
725
|
+
}
|
|
718
726
|
}
|
|
719
727
|
}
|
|
720
728
|
},
|
|
@@ -34,7 +34,10 @@ class FilesClient(Resource):
|
|
|
34
34
|
|
|
35
35
|
RESPONSE_CLASS = UploadResponse
|
|
36
36
|
|
|
37
|
-
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
37
|
+
def __init__(self, api_key: Optional[str] = None, *, http: Any = None, **options: Any) -> None:
|
|
38
|
+
if http is not None:
|
|
39
|
+
super().__init__(http)
|
|
40
|
+
return
|
|
38
41
|
resolved_api_key = auth.resolve_api_key(api_key)
|
|
39
42
|
client_options = ClientOptions(api_key=resolved_api_key, **options)
|
|
40
43
|
http = client_options.http_client or HttpClient(client_options)
|
|
@@ -28,15 +28,17 @@ class HttpClient:
|
|
|
28
28
|
|
|
29
29
|
def __init__(self, options: ClientOptions, *, transport: Optional[httpx.BaseTransport] = None) -> None:
|
|
30
30
|
self._options = options
|
|
31
|
+
headers = {
|
|
32
|
+
"Accept": "application/json",
|
|
33
|
+
"User-Agent": constants.SDK_USER_AGENT,
|
|
34
|
+
}
|
|
35
|
+
if options.api_key:
|
|
36
|
+
headers["Authorization"] = f"Bearer {options.api_key}"
|
|
31
37
|
self._client = httpx.Client(
|
|
32
38
|
base_url=options.base_url,
|
|
33
39
|
timeout=options.timeout,
|
|
34
40
|
transport=transport,
|
|
35
|
-
headers=
|
|
36
|
-
"Authorization": f"Bearer {options.api_key}",
|
|
37
|
-
"Accept": "application/json",
|
|
38
|
-
"User-Agent": constants.SDK_USER_AGENT,
|
|
39
|
-
},
|
|
41
|
+
headers=headers,
|
|
40
42
|
)
|
|
41
43
|
# A bare client for direct uploads: the pre-authorized upload URL lives
|
|
42
44
|
# outside the API host and must not receive the API key.
|
|
@@ -77,6 +79,9 @@ class HttpClient:
|
|
|
77
79
|
except httpx.TransportError as exc:
|
|
78
80
|
raise NetworkError(str(exc))
|
|
79
81
|
|
|
82
|
+
if response.status_code == 304:
|
|
83
|
+
return None
|
|
84
|
+
|
|
80
85
|
if response.is_success:
|
|
81
86
|
body = self._parse_body(response.text)
|
|
82
87
|
if body is None:
|
|
@@ -263,3 +263,23 @@ class TaskResponse(BaseModel):
|
|
|
263
263
|
id = optional(str)
|
|
264
264
|
status = optional(str)
|
|
265
265
|
error = optional(str)
|
|
266
|
+
billing = optional(lambda: TaskBillingFacts)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class BillingReservation(BaseModel):
|
|
270
|
+
amount_cents = required(int)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class BillingSettlement(BaseModel):
|
|
274
|
+
charged_amount_cents = required(int)
|
|
275
|
+
amount_micro_cents = required(int)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class BillingRefund(BaseModel):
|
|
279
|
+
refunded_at = required(str)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class TaskBillingFacts(BaseModel):
|
|
283
|
+
reservation = optional(BillingReservation)
|
|
284
|
+
settlement = optional(BillingSettlement)
|
|
285
|
+
refund = optional(BillingRefund)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Live Pricing API resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .auth import resolve_optional_api_key
|
|
8
|
+
from .models import BaseModel, optional, required
|
|
9
|
+
from .http_client import HttpClient
|
|
10
|
+
from .options import ClientOptions, RequestOptions
|
|
11
|
+
from .resource import Resource
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PriceSchedule(BaseModel):
|
|
15
|
+
service = required(str)
|
|
16
|
+
action = required(str)
|
|
17
|
+
model = optional(str)
|
|
18
|
+
pricing_status = required(str)
|
|
19
|
+
catalog_status = required(str)
|
|
20
|
+
currency = required(str)
|
|
21
|
+
billing_unit = optional(str)
|
|
22
|
+
billing_strategy = optional(str)
|
|
23
|
+
unit_price_cents = optional(int)
|
|
24
|
+
input_price_per_1m_cents = optional(int)
|
|
25
|
+
output_price_per_1m_cents = optional(int)
|
|
26
|
+
cache_read_price_per_1m_cents = optional(int)
|
|
27
|
+
cache_write_5m_price_per_1m_cents = optional(int)
|
|
28
|
+
cache_write_1h_price_per_1m_cents = optional(int)
|
|
29
|
+
billing_config = optional()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PriceScheduleListResponse(BaseModel):
|
|
33
|
+
as_of = required(str)
|
|
34
|
+
price_schedules = required([PriceSchedule])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PriceQuote(BaseModel):
|
|
38
|
+
service = required(str)
|
|
39
|
+
action = required(str)
|
|
40
|
+
model = optional(str)
|
|
41
|
+
pricing_status = required(str)
|
|
42
|
+
currency = required(str)
|
|
43
|
+
reservation_amount_cents = required(int)
|
|
44
|
+
estimate_basis = required(str)
|
|
45
|
+
as_of = required(str)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PriceQuoteResponse(BaseModel):
|
|
49
|
+
price_quote = required(PriceQuote)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Pricing(Resource):
|
|
53
|
+
"""Read current price schedules and create reservation estimates."""
|
|
54
|
+
|
|
55
|
+
SCHEDULES_ENDPOINT = "/api/v1/price_schedules"
|
|
56
|
+
QUOTES_ENDPOINT = "/api/v1/price_quotes"
|
|
57
|
+
|
|
58
|
+
def list_schedules(
|
|
59
|
+
self,
|
|
60
|
+
service: Optional[str] = None,
|
|
61
|
+
action: Optional[str] = None,
|
|
62
|
+
model: Optional[str] = None,
|
|
63
|
+
options: Optional[RequestOptions] = None,
|
|
64
|
+
) -> Optional[PriceScheduleListResponse]:
|
|
65
|
+
filters = self._compact_params({"service": service, "action": action, "model": model})
|
|
66
|
+
path = self.SCHEDULES_ENDPOINT
|
|
67
|
+
if filters:
|
|
68
|
+
from urllib.parse import urlencode
|
|
69
|
+
|
|
70
|
+
path = f"{path}?{urlencode(filters)}"
|
|
71
|
+
return self._request("get", path, options=options, response_class=PriceScheduleListResponse)
|
|
72
|
+
|
|
73
|
+
def create_quote(
|
|
74
|
+
self,
|
|
75
|
+
service: str,
|
|
76
|
+
action: str,
|
|
77
|
+
model: Optional[str] = None,
|
|
78
|
+
params: Optional[dict[str, Any]] = None,
|
|
79
|
+
options: Optional[RequestOptions] = None,
|
|
80
|
+
) -> PriceQuoteResponse:
|
|
81
|
+
body = {"service": service, "action": action, "model": model, "params": params}
|
|
82
|
+
return self._request(
|
|
83
|
+
"post", self.QUOTES_ENDPOINT, body=self._compact_params(body), options=options, response_class=PriceQuoteResponse
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PricingClient(Pricing):
|
|
88
|
+
"""Standalone Pricing client with optional API authentication."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
91
|
+
client_options = ClientOptions(api_key=resolve_optional_api_key(api_key), **options)
|
|
92
|
+
super().__init__(client_options.http_client or HttpClient(client_options))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Shared foundation for model-line Provider Clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .account import Account
|
|
8
|
+
from .auth import resolve_api_key
|
|
9
|
+
from .files import FilesClient
|
|
10
|
+
from .http_client import HttpClient
|
|
11
|
+
from .options import ClientOptions
|
|
12
|
+
from .pricing import Pricing
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProviderClient:
|
|
16
|
+
"""Resolve auth and expose universal resources on every model-line client."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
19
|
+
resolved_api_key = resolve_api_key(api_key)
|
|
20
|
+
client_options = ClientOptions(api_key=resolved_api_key, **options)
|
|
21
|
+
self._http = client_options.http_client or HttpClient(client_options)
|
|
22
|
+
self.files = FilesClient(http=self._http)
|
|
23
|
+
self.account = Account(self._http)
|
|
24
|
+
self.pricing = Pricing(self._http)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from runapi.core import Account, AccountBalanceResponse, AccountInfoResponse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FakeHttp:
|
|
5
|
+
def __init__(self, *responses):
|
|
6
|
+
self._responses = list(responses)
|
|
7
|
+
self.calls = []
|
|
8
|
+
|
|
9
|
+
def request(self, method, path, body=None, options=None):
|
|
10
|
+
self.calls.append((method, path, body, options))
|
|
11
|
+
return self._responses.pop(0)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_info_returns_typed_account_details():
|
|
15
|
+
http = FakeHttp({"id": 1, "name": "Developer", "email": "developer@example.com", "account": {"id": 2, "name": "Acme"}})
|
|
16
|
+
|
|
17
|
+
result = Account(http).info()
|
|
18
|
+
|
|
19
|
+
assert isinstance(result, AccountInfoResponse)
|
|
20
|
+
assert result.account.name == "Acme"
|
|
21
|
+
assert http.calls == [("get", "/api/v1/me", None, None)]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_balance_returns_typed_balance_details():
|
|
25
|
+
http = FakeHttp(
|
|
26
|
+
{
|
|
27
|
+
"balance_cents": 5000,
|
|
28
|
+
"paid_balance_cents": 4000,
|
|
29
|
+
"bonus_balance_cents": 1000,
|
|
30
|
+
"spent_cents_today": 100,
|
|
31
|
+
"spent_cents_total": 2000,
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
result = Account(http).balance()
|
|
36
|
+
|
|
37
|
+
assert isinstance(result, AccountBalanceResponse)
|
|
38
|
+
assert result.balance_cents == 5000
|
|
39
|
+
assert http.calls == [("get", "/api/v1/me/balance", None, None)]
|
|
@@ -57,6 +57,10 @@ def test_raises_when_no_source():
|
|
|
57
57
|
auth.resolve_api_key(None)
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def test_optional_resolution_returns_none_when_no_source():
|
|
61
|
+
assert auth.resolve_optional_api_key(None) is None
|
|
62
|
+
|
|
63
|
+
|
|
60
64
|
def test_configure_sets_global(monkeypatch):
|
|
61
65
|
config.configure(api_key="configured-key")
|
|
62
66
|
assert auth.resolve_api_key(None) == "configured-key"
|
|
@@ -50,6 +50,14 @@ def test_requires_exactly_one_source():
|
|
|
50
50
|
client.create(file="x", source="y")
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def test_accepts_an_existing_http_client_without_resolving_auth_again():
|
|
54
|
+
fake = FakeHttp(UPLOAD)
|
|
55
|
+
|
|
56
|
+
client = FilesClient(http=fake)
|
|
57
|
+
|
|
58
|
+
assert client._http is fake
|
|
59
|
+
|
|
60
|
+
|
|
53
61
|
def test_create_with_remote_source_sends_url_source_object():
|
|
54
62
|
fake = FakeHttp(UPLOAD)
|
|
55
63
|
client = FilesClient(api_key="k", http_client=fake)
|
|
@@ -93,6 +93,19 @@ def test_sends_bearer_and_user_agent():
|
|
|
93
93
|
assert captured["ua"] == constants.SDK_USER_AGENT
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def test_omits_authorization_header_without_an_api_key():
|
|
97
|
+
captured = {}
|
|
98
|
+
|
|
99
|
+
def handler(request):
|
|
100
|
+
captured["auth"] = request.headers.get("authorization")
|
|
101
|
+
return httpx.Response(200, json={})
|
|
102
|
+
|
|
103
|
+
options = ClientOptions(api_key=None, base_url="https://runapi.ai")
|
|
104
|
+
HttpClient(options, transport=httpx.MockTransport(handler)).request("get", "/api/v1/price_schedules")
|
|
105
|
+
|
|
106
|
+
assert captured["auth"] is None
|
|
107
|
+
|
|
108
|
+
|
|
96
109
|
def test_sends_json_body_for_post():
|
|
97
110
|
captured = {}
|
|
98
111
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from runapi.core import BaseModel, DynamicModel, optional, required
|
|
3
|
+
from runapi.core import BaseModel, DynamicModel, TaskBillingFacts, TaskResponse, optional, required
|
|
4
4
|
from runapi.core.errors import ValidationError
|
|
5
5
|
|
|
6
6
|
|
|
@@ -117,3 +117,16 @@ def test_response_headers_stay_outside_serialized_body():
|
|
|
117
117
|
assert model.response_header("x-runapi-task-id") == "task-ref-1"
|
|
118
118
|
assert model.runapi_task_id == "task-ref-1"
|
|
119
119
|
assert model.to_dict() == {"id": "abc"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_task_response_coerces_typed_billing_facts_and_preserves_unknown_fields():
|
|
123
|
+
response = TaskResponse(
|
|
124
|
+
{
|
|
125
|
+
"id": "task_1",
|
|
126
|
+
"billing": {"reservation": {"amount_cents": 5}, "future_fact": "retained"},
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert isinstance(response.billing, TaskBillingFacts)
|
|
131
|
+
assert response.billing.reservation.amount_cents == 5
|
|
132
|
+
assert response.billing.future_fact == "retained"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from runapi.core import ApiResponse, ClientOptions, HttpClient, PriceQuoteResponse, PriceScheduleListResponse, Pricing, PricingClient, RequestOptions, config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FakeHttp:
|
|
8
|
+
def __init__(self, *responses):
|
|
9
|
+
self._responses = list(responses)
|
|
10
|
+
self.calls = []
|
|
11
|
+
|
|
12
|
+
def request(self, method, path, body=None, options=None):
|
|
13
|
+
self.calls.append((method, path, body, options))
|
|
14
|
+
return self._responses.pop(0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SCHEDULE = {
|
|
18
|
+
"service": "nano_banana",
|
|
19
|
+
"action": "text_to_image",
|
|
20
|
+
"model": "nano-banana",
|
|
21
|
+
"pricing_status": "available",
|
|
22
|
+
"catalog_status": "active",
|
|
23
|
+
"currency": "USD",
|
|
24
|
+
"billing_unit": "per_call",
|
|
25
|
+
"billing_strategy": "flat",
|
|
26
|
+
"unit_price_cents": 5,
|
|
27
|
+
"future_field": {"preserved": True},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(autouse=True)
|
|
32
|
+
def reset_config(monkeypatch):
|
|
33
|
+
monkeypatch.delenv("RUNAPI_API_KEY", raising=False)
|
|
34
|
+
monkeypatch.setattr(config, "api_key", None)
|
|
35
|
+
yield
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_list_schedules_uses_live_endpoint_filters_and_revalidation_options():
|
|
39
|
+
options = RequestOptions(headers={"If-None-Match": '"schedule-v1"'})
|
|
40
|
+
http = FakeHttp(ApiResponse({"as_of": "2026-07-23T00:00:00.000000Z", "price_schedules": [SCHEDULE]}, {"ETag": '"schedule-v2"'}))
|
|
41
|
+
|
|
42
|
+
result = Pricing(http).list_schedules(
|
|
43
|
+
service="nano_banana", action="text_to_image", model="nano-banana", options=options
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
assert isinstance(result, PriceScheduleListResponse)
|
|
47
|
+
assert result.price_schedules[0].future_field.preserved is True
|
|
48
|
+
assert result.response_header("etag") == '"schedule-v2"'
|
|
49
|
+
assert http.calls == [
|
|
50
|
+
("get", "/api/v1/price_schedules?service=nano_banana&action=text_to_image&model=nano-banana", None, options)
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_create_quote_is_anonymous_by_default_and_returns_a_typed_quote():
|
|
55
|
+
http = FakeHttp(
|
|
56
|
+
{
|
|
57
|
+
"price_quote": {
|
|
58
|
+
"service": "nano_banana",
|
|
59
|
+
"action": "text_to_image",
|
|
60
|
+
"model": "nano-banana",
|
|
61
|
+
"pricing_status": "available",
|
|
62
|
+
"currency": "USD",
|
|
63
|
+
"reservation_amount_cents": 5,
|
|
64
|
+
"estimate_basis": "exact",
|
|
65
|
+
"as_of": "2026-07-23T00:00:00.000000Z",
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
result = Pricing(http).create_quote(
|
|
71
|
+
service="nano_banana", action="text_to_image", model="nano-banana", params={"prompt": "A glass observatory"}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert isinstance(result, PriceQuoteResponse)
|
|
75
|
+
assert result.price_quote.reservation_amount_cents == 5
|
|
76
|
+
assert http.calls == [
|
|
77
|
+
(
|
|
78
|
+
"post",
|
|
79
|
+
"/api/v1/price_quotes",
|
|
80
|
+
{"service": "nano_banana", "action": "text_to_image", "model": "nano-banana", "params": {"prompt": "A glass observatory"}},
|
|
81
|
+
None,
|
|
82
|
+
)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_standalone_client_allows_anonymous_schedule_requests():
|
|
87
|
+
captured = {}
|
|
88
|
+
|
|
89
|
+
def handler(request):
|
|
90
|
+
captured["authorization"] = request.headers.get("authorization")
|
|
91
|
+
return httpx.Response(200, json={"as_of": "2026-07-23T00:00:00.000000Z", "price_schedules": []})
|
|
92
|
+
|
|
93
|
+
http = HttpClient(
|
|
94
|
+
ClientOptions(api_key=None, base_url="https://runapi.ai"), transport=httpx.MockTransport(handler)
|
|
95
|
+
)
|
|
96
|
+
result = PricingClient(http_client=http).list_schedules()
|
|
97
|
+
|
|
98
|
+
assert isinstance(result, PriceScheduleListResponse)
|
|
99
|
+
assert captured["authorization"] is None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_schedule_revalidation_returns_none_for_not_modified():
|
|
103
|
+
http = HttpClient(
|
|
104
|
+
ClientOptions(api_key=None, base_url="https://runapi.ai"),
|
|
105
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(304, headers={"ETag": '"schedule-v1"'})),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
result = PricingClient(http_client=http).list_schedules(options=RequestOptions(headers={"If-None-Match": '"schedule-v1"'}))
|
|
109
|
+
|
|
110
|
+
assert result is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_standalone_client_uses_configured_api_key_when_a_quote_needs_auth(monkeypatch):
|
|
114
|
+
monkeypatch.setattr(config, "api_key", "configured-key")
|
|
115
|
+
client = PricingClient()
|
|
116
|
+
try:
|
|
117
|
+
assert client._http._client.headers["authorization"] == "Bearer configured-key"
|
|
118
|
+
finally:
|
|
119
|
+
client._http.close()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|