askai-python 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

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.
ask_ai/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
  ask-ai: A minimal Python SDK to switch between LLM providers in one line.
3
3
  """
4
4
 
5
- __version__ = "0.5.0"
5
+ __version__ = "0.6.0"
6
6
 
7
7
  from .base import Response
8
8
  from .capabilities import Capabilities
@@ -17,6 +17,7 @@ from .exceptions import (
17
17
  MediaSecurityError,
18
18
  MediaTypeNotSupportedError,
19
19
  ProviderAuthError,
20
+ ProviderConnectionError,
20
21
  ProviderError,
21
22
  ProviderInvalidRequestError,
22
23
  ProviderRateLimitError,
@@ -54,6 +55,7 @@ __all__ = [
54
55
  "AskAIParsingError",
55
56
  "ProviderError",
56
57
  "ProviderAuthError",
58
+ "ProviderConnectionError",
57
59
  "ProviderRateLimitError",
58
60
  "ProviderTimeoutError",
59
61
  "ProviderInvalidRequestError",
ask_ai/base.py CHANGED
@@ -7,13 +7,14 @@ from collections.abc import AsyncIterator, Iterator
7
7
  from typing import Any, Optional, Union
8
8
 
9
9
  from .capabilities import Capabilities
10
- from .config import AdvancedConfig
10
+ from .config import CONFIG_OPTION_NAMES, AdvancedConfig, validate_option_names
11
11
  from .events import Completed, Error, TextDelta, ToolCall, UsageUpdate
12
12
  from .exceptions import StructuredValidationError
13
13
  from .media import AudioObject, ImageObject
14
14
  from .observability import Observability, ResponseMetadata, Usage
15
15
 
16
16
  logger = logging.getLogger("ask_ai")
17
+ DEFAULT_RETRY_COUNT = 2
17
18
 
18
19
 
19
20
  class Response:
@@ -26,12 +27,14 @@ class Response:
26
27
  media: Union[ImageObject, AudioObject, None] = None,
27
28
  response_model: Any = None,
28
29
  metadata: Any = None,
30
+ usage: Optional[Usage] = None,
29
31
  **kwargs,
30
32
  ):
31
33
  self._raw_text = text
32
34
  self.media = media
33
35
  self.response_model = response_model
34
36
  self.metadata = metadata
37
+ self.usage = usage
35
38
  self._kwargs = kwargs
36
39
 
37
40
  @property
@@ -110,7 +113,18 @@ class BaseProvider:
110
113
  Abstract base class for all AI providers.
111
114
  Implements the core 'ask' logic and configuration management.
112
115
  """
113
- def __init__(self, api_key: str = None, model: str = None, persona: str = None, **kwargs):
116
+ option_names = frozenset()
117
+
118
+ def __init__(
119
+ self,
120
+ api_key: str = None,
121
+ model: str = None,
122
+ persona: str = None,
123
+ strict_options: bool = False,
124
+ **kwargs,
125
+ ):
126
+ self.strict_options = strict_options
127
+ self._validate_option_names(kwargs, CONFIG_OPTION_NAMES | self.option_names, strict_options)
114
128
  # Zero-Config: Try env var if key not provided
115
129
  self.api_key = api_key or os.environ.get(self._get_api_key_env_var())
116
130
  self.model = model or self._get_default_model()
@@ -118,14 +132,33 @@ class BaseProvider:
118
132
  self.config = AdvancedConfig(**kwargs)
119
133
  self._middleware: list[Observability] = []
120
134
 
135
+ @staticmethod
136
+ def _validate_option_names(options, allowed, strict=False):
137
+ validate_option_names(options, allowed, strict)
138
+
121
139
  def use(self, middleware: Observability) -> "BaseProvider":
122
140
  """Attach optional observability middleware and return this provider."""
123
141
  self._middleware.append(middleware)
124
142
  return self
125
143
 
126
- def session(self, max_messages: Optional[int] = None):
144
+ def session(
145
+ self,
146
+ max_messages: Optional[int] = None,
147
+ strict_options: Optional[bool] = None,
148
+ **kwargs,
149
+ ):
150
+ strict = (
151
+ getattr(self, "strict_options", False)
152
+ if strict_options is None
153
+ else strict_options
154
+ )
155
+ validate_option_names(kwargs, {"max_messages", "strict_options"}, strict)
127
156
  from .session import ConversationSession
128
- return ConversationSession(self, max_messages=max_messages)
157
+ return ConversationSession(
158
+ self,
159
+ max_messages=max_messages,
160
+ strict_options=strict,
161
+ )
129
162
 
130
163
  def capabilities(self) -> Capabilities:
131
164
  """Return conservative capabilities for custom providers."""
@@ -197,6 +230,13 @@ class BaseProvider:
197
230
  response = self._execute_with_retry(
198
231
  self._send_request, request_messages, config, output_type, retry
199
232
  )
233
+ response._kwargs.update(
234
+ {
235
+ option: config.extra[option]
236
+ for option in ("clean", "strip", "code")
237
+ if option in config.extra
238
+ }
239
+ )
200
240
  retry_count += getattr(self, "_retry_count", 0)
201
241
  if response_model is None:
202
242
  return self._attach_metadata(response, started_at, retry_count)
@@ -234,6 +274,13 @@ class BaseProvider:
234
274
  response = await self._execute_with_retry_async(
235
275
  self._send_request_async, request_messages, config, output_type, retry
236
276
  )
277
+ response._kwargs.update(
278
+ {
279
+ option: config.extra[option]
280
+ for option in ("clean", "strip", "code")
281
+ if option in config.extra
282
+ }
283
+ )
237
284
  retry_count += getattr(self, "_retry_count", 0)
238
285
  if response_model is None:
239
286
  return self._attach_metadata(response, started_at, retry_count)
@@ -265,6 +312,8 @@ class BaseProvider:
265
312
  Update global advanced settings for this instance.
266
313
  Merges new settings with existing ones.
267
314
  """
315
+ strict = kwargs.pop("strict_options", self.strict_options)
316
+ self._validate_option_names(kwargs, CONFIG_OPTION_NAMES | self.option_names, strict)
268
317
  new_conf = AdvancedConfig(**kwargs)
269
318
  for k, v in new_conf.__dict__.items():
270
319
  if k in ('extra', '_safe_mode_provided'):
@@ -308,7 +357,9 @@ class BaseProvider:
308
357
  final_config = self._build_final_config(kwargs)
309
358
  messages = self._prepare_messages(query, final_config)
310
359
  output_type = kwargs.get('output_type')
311
- retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
360
+ retry = int(
361
+ kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT)) or 0
362
+ )
312
363
  response_model = kwargs.get('response_model')
313
364
  validation_retries = int(kwargs.get("validation_retries", 0) or 0)
314
365
 
@@ -352,7 +403,14 @@ class BaseProvider:
352
403
 
353
404
  final_config = self._build_final_config(kwargs)
354
405
  messages = self._prepare_messages(query, final_config)
355
- yield from self._send_request_stream(messages, final_config)
406
+ retry = int(
407
+ kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT))
408
+ or 0
409
+ )
410
+ yield from self._iter_with_retry(
411
+ lambda: self._send_request_stream(messages, final_config),
412
+ retry,
413
+ )
356
414
 
357
415
  def ask_stream_events(self, query: str, **kwargs) -> Iterator[Any]:
358
416
  """Yield typed events while preserving the legacy text stream API."""
@@ -360,18 +418,44 @@ class BaseProvider:
360
418
  if fallback_providers:
361
419
  if not isinstance(fallback_providers, (list, tuple)):
362
420
  fallback_providers = [fallback_providers]
421
+ last_error = None
363
422
  for provider in fallback_providers:
364
- provider_instance = provider() if isinstance(provider, type) else provider
365
- yield from provider_instance.ask_stream_events(query, **kwargs)
366
- return
423
+ emitted = False
424
+ try:
425
+ provider_instance = provider() if isinstance(provider, type) else provider
426
+ for event in provider_instance.ask_stream_events(query, **kwargs):
427
+ if isinstance(event, Error):
428
+ if not emitted and event.emitted_chunks == 0:
429
+ last_error = event
430
+ break
431
+ yield event
432
+ return
433
+ emitted = True
434
+ yield event
435
+ else:
436
+ return
437
+ except Exception as error:
438
+ if emitted:
439
+ raise
440
+ last_error = Error(error=error, emitted_chunks=0)
441
+ if last_error is not None:
442
+ yield last_error
443
+ return
367
444
 
368
445
  emitted_chunks = 0
369
446
  text_parts = []
370
447
  usage = None
371
448
  final_config = self._build_final_config(kwargs)
372
449
  messages = self._prepare_messages(query, final_config)
450
+ retry = int(
451
+ kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT))
452
+ or 0
453
+ )
373
454
  try:
374
- for event in self._send_request_stream_events(messages, final_config):
455
+ for event in self._iter_with_retry(
456
+ lambda: self._send_request_stream_events(messages, final_config),
457
+ retry,
458
+ ):
375
459
  if isinstance(event, TextDelta):
376
460
  emitted_chunks += 1
377
461
  text_parts.append(event.text)
@@ -419,7 +503,9 @@ class BaseProvider:
419
503
  final_config = self._build_final_config(kwargs)
420
504
  messages = self._prepare_messages(query, final_config)
421
505
  output_type = kwargs.get('output_type')
422
- retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
506
+ retry = int(
507
+ kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT)) or 0
508
+ )
423
509
  response_model = kwargs.get('response_model')
424
510
  validation_retries = int(kwargs.get("validation_retries", 0) or 0)
425
511
 
@@ -463,7 +549,14 @@ class BaseProvider:
463
549
 
464
550
  final_config = self._build_final_config(kwargs)
465
551
  messages = self._prepare_messages(query, final_config)
466
- async for chunk in self._send_request_stream_async(messages, final_config):
552
+ retry = int(
553
+ kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT))
554
+ or 0
555
+ )
556
+ async for chunk in self._aiter_with_retry(
557
+ lambda: self._send_request_stream_async(messages, final_config),
558
+ retry,
559
+ ):
467
560
  yield chunk
468
561
 
469
562
  async def ask_stream_events_async(self, query: str, **kwargs) -> AsyncIterator[Any]:
@@ -472,19 +565,44 @@ class BaseProvider:
472
565
  if fallback_providers:
473
566
  if not isinstance(fallback_providers, (list, tuple)):
474
567
  fallback_providers = [fallback_providers]
568
+ last_error = None
475
569
  for provider in fallback_providers:
476
- provider_instance = provider() if isinstance(provider, type) else provider
477
- async for event in provider_instance.ask_stream_events_async(query, **kwargs):
478
- yield event
479
- return
570
+ emitted = False
571
+ try:
572
+ provider_instance = provider() if isinstance(provider, type) else provider
573
+ async for event in provider_instance.ask_stream_events_async(query, **kwargs):
574
+ if isinstance(event, Error):
575
+ if not emitted and event.emitted_chunks == 0:
576
+ last_error = event
577
+ break
578
+ yield event
579
+ return
580
+ emitted = True
581
+ yield event
582
+ else:
583
+ return
584
+ except Exception as error:
585
+ if emitted:
586
+ raise
587
+ last_error = Error(error=error, emitted_chunks=0)
588
+ if last_error is not None:
589
+ yield last_error
590
+ return
480
591
 
481
592
  emitted_chunks = 0
482
593
  text_parts = []
483
594
  usage = None
484
595
  final_config = self._build_final_config(kwargs)
485
596
  messages = self._prepare_messages(query, final_config)
597
+ retry = int(
598
+ kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT))
599
+ or 0
600
+ )
486
601
  try:
487
- async for event in self._send_request_stream_events_async(messages, final_config):
602
+ async for event in self._aiter_with_retry(
603
+ lambda: self._send_request_stream_events_async(messages, final_config),
604
+ retry,
605
+ ):
488
606
  if isinstance(event, TextDelta):
489
607
  emitted_chunks += 1
490
608
  text_parts.append(event.text)
@@ -504,11 +622,20 @@ class BaseProvider:
504
622
 
505
623
  def _build_final_config(self, kwargs: dict[str, Any]) -> AdvancedConfig:
506
624
  """Build final config by merging global config with per-request kwargs."""
507
- request_config = AdvancedConfig(**kwargs)
625
+ options = dict(kwargs)
626
+ strict = options.pop("strict_options", getattr(self, "strict_options", False))
627
+ validate_option_names(
628
+ options,
629
+ CONFIG_OPTION_NAMES
630
+ | self.option_names
631
+ | {"strict_options", "providers", "fallback"},
632
+ strict,
633
+ )
634
+ request_config = AdvancedConfig(**options)
508
635
  final_config = self.config.merge(request_config)
509
636
 
510
637
  # Ensure timeout has a default
511
- timeout = kwargs.get('timeout', final_config.extra.get('timeout', 30))
638
+ timeout = options.get('timeout', final_config.extra.get('timeout', 30))
512
639
  final_config.extra['timeout'] = float(timeout) if timeout is not None else 30.0
513
640
 
514
641
  return final_config
@@ -521,14 +648,10 @@ class BaseProvider:
521
648
 
522
649
  # Smart capability: if json=True or response_model is specified
523
650
  response_model = config.extra.get('response_model')
524
- if response_model:
525
- if hasattr(response_model, "model_json_schema"):
526
- schema = response_model.model_json_schema()
527
- elif hasattr(response_model, "schema"):
528
- schema = response_model.schema()
529
- else:
530
- schema = None
651
+ if response_model is not None:
652
+ from .structured import json_schema_for_model
531
653
 
654
+ schema = json_schema_for_model(response_model)
532
655
  if schema:
533
656
  import json
534
657
  schema_str = json.dumps(schema)
@@ -555,6 +678,58 @@ class BaseProvider:
555
678
 
556
679
  # ─── Retry Logic ────────────────────────────────────────
557
680
 
681
+ def _iter_with_retry(self, iterator_factory, retry: int):
682
+ attempts = 0
683
+ self._retry_count = 0
684
+ while True:
685
+ emitted = False
686
+ try:
687
+ for item in iterator_factory():
688
+ emitted = True
689
+ yield item
690
+ self._retry_count = attempts
691
+ return
692
+ except Exception as error:
693
+ if emitted or attempts >= retry or not self._is_retryable_error(error):
694
+ raise
695
+ attempts += 1
696
+ self._retry_count = attempts
697
+ delay = min(2 ** attempts + random.uniform(0, 1), 30)
698
+ logger.warning(
699
+ "Transient streaming error (%s). Retrying in %.1fs... (%d/%d)",
700
+ error.__class__.__name__,
701
+ delay,
702
+ attempts,
703
+ retry,
704
+ )
705
+ time.sleep(delay)
706
+
707
+ async def _aiter_with_retry(self, iterator_factory, retry: int):
708
+ attempts = 0
709
+ self._retry_count = 0
710
+ while True:
711
+ emitted = False
712
+ try:
713
+ async for item in iterator_factory():
714
+ emitted = True
715
+ yield item
716
+ self._retry_count = attempts
717
+ return
718
+ except Exception as error:
719
+ if emitted or attempts >= retry or not self._is_retryable_error(error):
720
+ raise
721
+ attempts += 1
722
+ self._retry_count = attempts
723
+ delay = min(2 ** attempts + random.uniform(0, 1), 30)
724
+ logger.warning(
725
+ "Transient async streaming error (%s). Retrying in %.1fs... (%d/%d)",
726
+ error.__class__.__name__,
727
+ delay,
728
+ attempts,
729
+ retry,
730
+ )
731
+ await asyncio.sleep(delay)
732
+
558
733
  def _execute_with_retry(self, func, messages, config, output_type, retry):
559
734
  """Execute a sync function with exponential backoff + jitter."""
560
735
  attempts = 0
ask_ai/config.py CHANGED
@@ -1,7 +1,49 @@
1
- from typing import Any, Optional
1
+ import difflib
2
+ import warnings
3
+ from typing import Any, Iterable, Optional
2
4
 
3
5
  _NOT_PROVIDED = object()
4
6
 
7
+ CONFIG_OPTION_NAMES = frozenset(
8
+ {
9
+ "temperature",
10
+ "max_tokens",
11
+ "top_p",
12
+ "frequency_penalty",
13
+ "presence_penalty",
14
+ "stop",
15
+ "system_message",
16
+ "prompt",
17
+ "system",
18
+ "safe_mode",
19
+ "timeout",
20
+ "retry",
21
+ "response_model",
22
+ "validation_retries",
23
+ "output_type",
24
+ "clean",
25
+ "strip",
26
+ "code",
27
+ "json",
28
+ }
29
+ )
30
+
31
+
32
+ def validate_option_names(
33
+ options: Iterable[str],
34
+ allowed: Iterable[str],
35
+ strict: bool = False,
36
+ ) -> None:
37
+ allowed_set = set(allowed)
38
+ allowed_names = sorted(allowed_set)
39
+ for name in sorted(set(options) - allowed_set):
40
+ suggestions = difflib.get_close_matches(name, allowed_names, n=1, cutoff=0.6)
41
+ suggestion = f" Did you mean '{suggestions[0]}'?" if suggestions else ""
42
+ message = f"Unknown option '{name}'.{suggestion}"
43
+ if strict:
44
+ raise ValueError(message)
45
+ warnings.warn(message, UserWarning, stacklevel=3)
46
+
5
47
 
6
48
  class AdvancedConfig:
7
49
  """
@@ -37,6 +79,12 @@ class AdvancedConfig:
37
79
  kwargs.pop("system", None)
38
80
 
39
81
  self.system_message = system_message
82
+ if safe_mode is not _NOT_PROVIDED:
83
+ warnings.warn(
84
+ "safe_mode is deprecated and has no defined behavior; it will be removed in a future release.",
85
+ DeprecationWarning,
86
+ stacklevel=2,
87
+ )
40
88
  self.safe_mode = False if safe_mode is _NOT_PROVIDED else safe_mode
41
89
  self._safe_mode_provided = safe_mode is not _NOT_PROVIDED
42
90
  self.extra: dict[str, Any] = kwargs
ask_ai/exceptions.py CHANGED
@@ -38,6 +38,11 @@ class ProviderTimeoutError(AskAINetworkError, ProviderError):
38
38
  pass
39
39
 
40
40
 
41
+ class ProviderConnectionError(AskAINetworkError, ProviderError):
42
+ """Raised when a provider request fails due to a network connection error."""
43
+ pass
44
+
45
+
41
46
  class ProviderInvalidRequestError(ProviderError):
42
47
  """Raised when a provider rejects request parameters."""
43
48
  pass
@@ -83,6 +88,8 @@ def map_provider_exception(error: Exception, provider: str, operation: str = "re
83
88
  if status_code is None:
84
89
  response = getattr(error, "response", None)
85
90
  status_code = getattr(response, "status_code", None)
91
+ if status_code is None:
92
+ status_code = getattr(error, "code", None)
86
93
 
87
94
  error_text = str(error).lower()
88
95
  error_name = error.__class__.__name__.lower()
@@ -98,11 +105,19 @@ def map_provider_exception(error: Exception, provider: str, operation: str = "re
98
105
  for marker in ("ratelimit", "rate_limit", "rate limit", "too many requests")
99
106
  ):
100
107
  return ProviderRateLimitError(f"{prefix}: {error}")
108
+ if any(marker in error_name or marker in error_text for marker in ("timeout", "timed out")):
109
+ return ProviderTimeoutError(f"{prefix}: {error}")
101
110
  if any(
102
111
  marker in error_name or marker in error_text
103
- for marker in ("timeout", "timed out", "connecterror", "connectionerror", "connection reset")
112
+ for marker in (
113
+ "connecterror",
114
+ "connectionerror",
115
+ "connection reset",
116
+ "connection refused",
117
+ "connection error",
118
+ )
104
119
  ):
105
- return ProviderTimeoutError(f"{prefix}: {error}")
120
+ return ProviderConnectionError(f"{prefix}: {error}")
106
121
  if isinstance(status_code, int) and status_code >= 500:
107
122
  return ProviderUnavailableError(f"{prefix}: {error}")
108
123
  if status_code in (400, 404, 422) or any(
ask_ai/media_security.py CHANGED
@@ -16,7 +16,7 @@ class MediaPolicy:
16
16
  timeout: float = 30.0
17
17
  allow_private: bool = False
18
18
 
19
- def validate_url(self, url: str) -> None:
19
+ def _validated_addresses(self, url: str) -> tuple[str, list[str]]:
20
20
  parsed = urlparse(url)
21
21
  if parsed.scheme != "https" or not parsed.hostname:
22
22
  raise MediaSecurityError("Media URLs must use HTTPS and include a hostname.")
@@ -53,6 +53,10 @@ class MediaPolicy:
53
53
  raise MediaSecurityError(
54
54
  f"Media host resolves to a non-public IP address: {address}"
55
55
  )
56
+ return parsed.hostname, [str(address) for address in addresses]
57
+
58
+ def validate_url(self, url: str) -> None:
59
+ self._validated_addresses(url)
56
60
 
57
61
  def validate_mime(self, mime_type: str) -> None:
58
62
  if self.allowed_mime_types and mime_type not in self.allowed_mime_types:
@@ -62,14 +66,19 @@ class MediaPolicy:
62
66
  def download_media(url: str, policy: Optional[MediaPolicy] = None) -> bytes:
63
67
  """Stream media bytes with URL, MIME, size, and timeout validation."""
64
68
  policy = policy or MediaPolicy()
65
- policy.validate_url(url)
69
+ hostname, addresses = policy._validated_addresses(url)
66
70
  try:
67
71
  import httpx
68
72
  except ImportError as error:
69
73
  raise MediaSecurityError("httpx is required for secure media downloads.") from error
70
74
 
71
75
  try:
72
- with httpx.Client(timeout=policy.timeout, follow_redirects=False) as client:
76
+ transport = _create_pinned_transport(httpx, hostname, addresses)
77
+ with httpx.Client(
78
+ timeout=policy.timeout,
79
+ follow_redirects=False,
80
+ transport=transport,
81
+ ) as client:
73
82
  with client.stream("GET", url) as response:
74
83
  if 300 <= response.status_code < 400:
75
84
  raise MediaSecurityError(
@@ -103,3 +112,41 @@ def download_media(url: str, policy: Optional[MediaPolicy] = None) -> bytes:
103
112
  raise
104
113
  except Exception as error:
105
114
  raise MediaSecurityError(f"Secure media download failed: {error}") from error
115
+
116
+
117
+ def _create_pinned_transport(httpx, hostname: str, addresses: list[str]):
118
+ import httpcore
119
+
120
+ normalized_hostname = hostname.encode("idna").decode("ascii").lower().rstrip(".")
121
+
122
+ class PinnedNetworkBackend(httpcore.SyncBackend):
123
+ def connect_tcp(
124
+ self,
125
+ host,
126
+ port,
127
+ timeout=None,
128
+ local_address=None,
129
+ socket_options=None,
130
+ ):
131
+ if host.lower().rstrip(".") != normalized_hostname:
132
+ raise httpcore.ConnectError(
133
+ "Media transport attempted to connect to an unvalidated host."
134
+ )
135
+
136
+ last_error = None
137
+ for address in addresses:
138
+ try:
139
+ return super().connect_tcp(
140
+ address,
141
+ port,
142
+ timeout=timeout,
143
+ local_address=local_address,
144
+ socket_options=socket_options,
145
+ )
146
+ except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
147
+ last_error = error
148
+ raise last_error
149
+
150
+ transport = httpx.HTTPTransport()
151
+ transport._pool._network_backend = PinnedNetworkBackend()
152
+ return transport
ask_ai/observability.py CHANGED
@@ -9,6 +9,37 @@ class Usage:
9
9
  total_tokens: Optional[int] = None
10
10
 
11
11
 
12
+ def usage_from_fields(
13
+ source: Any,
14
+ input_field: str,
15
+ output_field: str,
16
+ total_field: Optional[str] = None,
17
+ ) -> Optional[Usage]:
18
+ """Normalize provider-specific token usage fields without inventing missing counts."""
19
+ if source is None:
20
+ return None
21
+
22
+ def field(name: Optional[str]) -> Optional[int]:
23
+ if name is None:
24
+ return None
25
+ if isinstance(source, dict):
26
+ return source.get(name)
27
+ return getattr(source, name, None)
28
+
29
+ input_tokens = field(input_field)
30
+ output_tokens = field(output_field)
31
+ total_tokens = field(total_field)
32
+ if input_tokens is None and output_tokens is None and total_tokens is None:
33
+ return None
34
+ if total_tokens is None and input_tokens is not None and output_tokens is not None:
35
+ total_tokens = input_tokens + output_tokens
36
+ return Usage(
37
+ input_tokens=input_tokens,
38
+ output_tokens=output_tokens,
39
+ total_tokens=total_tokens,
40
+ )
41
+
42
+
12
43
  @dataclass(frozen=True)
13
44
  class ResponseMetadata:
14
45
  provider: str