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.
Files changed (33) hide show
  1. {runapi_core-0.2.4 → runapi_core-0.3.0}/PKG-INFO +33 -4
  2. {runapi_core-0.2.4 → runapi_core-0.3.0}/README.md +31 -2
  3. {runapi_core-0.2.4 → runapi_core-0.3.0}/pyproject.toml +1 -1
  4. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/__init__.py +31 -2
  5. runapi_core-0.3.0/src/runapi/core/account.py +42 -0
  6. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/auth.py +6 -1
  7. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/contract_gen.py +9 -1
  8. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/files.py +4 -1
  9. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/http_client.py +10 -5
  10. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/models.py +20 -0
  11. runapi_core-0.3.0/src/runapi/core/pricing.py +92 -0
  12. runapi_core-0.3.0/src/runapi/core/provider_client.py +24 -0
  13. runapi_core-0.3.0/src/runapi/core/version.py +1 -0
  14. runapi_core-0.3.0/tests/test_account.py +39 -0
  15. {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_auth.py +4 -0
  16. {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_files.py +8 -0
  17. {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_http_client.py +13 -0
  18. {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_models.py +14 -1
  19. runapi_core-0.3.0/tests/test_pricing.py +119 -0
  20. runapi_core-0.2.4/src/runapi/core/version.py +0 -1
  21. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/config.py +0 -0
  22. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/constants.py +0 -0
  23. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/errors.py +0 -0
  24. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/multipart.py +0 -0
  25. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/options.py +0 -0
  26. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/polling.py +0 -0
  27. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/py.typed +0 -0
  28. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/resource.py +0 -0
  29. {runapi_core-0.2.4 → runapi_core-0.3.0}/src/runapi/core/response.py +0 -0
  30. {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_config.py +0 -0
  31. {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_errors.py +0 -0
  32. {runapi_core-0.2.4 → runapi_core-0.3.0}/tests/test_polling.py +0 -0
  33. {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.2.4
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.2.4
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={"X-Client-Request-Id": "order-123"},
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={"X-Client-Request-Id": "order-123"},
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.2.4"
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 BaseModel, DynamicModel, TaskResponse, optional, required
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 = _normalize(explicit) or _normalize(config.api_key) or _normalize(os.environ.get(ENV_VAR_NAME))
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"