retab 0.0.40__py3-none-any.whl → 0.0.42__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.
Files changed (53) hide show
  1. retab/client.py +5 -5
  2. retab/resources/consensus/completions.py +1 -1
  3. retab/resources/consensus/completions_stream.py +5 -5
  4. retab/resources/consensus/responses.py +1 -1
  5. retab/resources/consensus/responses_stream.py +2 -2
  6. retab/resources/documents/client.py +12 -11
  7. retab/resources/documents/extractions.py +4 -4
  8. retab/resources/evals.py +1 -1
  9. retab/resources/evaluations/documents.py +1 -1
  10. retab/resources/jsonlUtils.py +4 -4
  11. retab/resources/processors/automations/endpoints.py +9 -5
  12. retab/resources/processors/automations/links.py +2 -2
  13. retab/resources/processors/automations/logs.py +2 -2
  14. retab/resources/processors/automations/mailboxes.py +43 -32
  15. retab/resources/processors/automations/outlook.py +25 -7
  16. retab/resources/processors/automations/tests.py +8 -2
  17. retab/resources/processors/client.py +25 -16
  18. retab/resources/prompt_optimization.py +1 -1
  19. retab/resources/schemas.py +3 -3
  20. retab/types/automations/mailboxes.py +1 -1
  21. retab/types/completions.py +1 -1
  22. retab/types/documents/create_messages.py +4 -4
  23. retab/types/documents/extractions.py +3 -3
  24. retab/types/documents/parse.py +3 -1
  25. retab/types/evals.py +2 -2
  26. retab/types/evaluations/iterations.py +2 -2
  27. retab/types/evaluations/model.py +2 -2
  28. retab/types/extractions.py +34 -9
  29. retab/types/jobs/prompt_optimization.py +1 -1
  30. retab/types/logs.py +3 -3
  31. retab/types/schemas/object.py +4 -4
  32. retab/types/schemas/templates.py +1 -1
  33. retab/utils/__init__.py +0 -0
  34. retab/utils/_model_cards/anthropic.yaml +59 -0
  35. retab/utils/_model_cards/auto.yaml +43 -0
  36. retab/utils/_model_cards/gemini.yaml +117 -0
  37. retab/utils/_model_cards/openai.yaml +301 -0
  38. retab/utils/_model_cards/xai.yaml +28 -0
  39. retab/utils/ai_models.py +138 -0
  40. retab/utils/benchmarking.py +484 -0
  41. retab/utils/chat.py +327 -0
  42. retab/utils/display.py +440 -0
  43. retab/utils/json_schema.py +2156 -0
  44. retab/utils/mime.py +165 -0
  45. retab/utils/responses.py +169 -0
  46. retab/utils/stream_context_managers.py +52 -0
  47. retab/utils/usage/__init__.py +0 -0
  48. retab/utils/usage/usage.py +301 -0
  49. retab-0.0.42.dist-info/METADATA +119 -0
  50. {retab-0.0.40.dist-info → retab-0.0.42.dist-info}/RECORD +52 -36
  51. retab-0.0.40.dist-info/METADATA +0 -418
  52. {retab-0.0.40.dist-info → retab-0.0.42.dist-info}/WHEEL +0 -0
  53. {retab-0.0.40.dist-info → retab-0.0.42.dist-info}/top_level.txt +0 -0
retab/utils/mime.py ADDED
@@ -0,0 +1,165 @@
1
+ import base64
2
+ import hashlib
3
+ import io
4
+ import json
5
+ import mimetypes
6
+ from pathlib import Path
7
+ from typing import Sequence, TypeVar, get_args
8
+
9
+ import httpx
10
+ import PIL.Image
11
+ import puremagic
12
+ from pydantic import HttpUrl
13
+
14
+ from ..types.mime import MIMEData
15
+ from ..types.modalities import SUPPORTED_TYPES
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ def generate_blake2b_hash_from_bytes(bytes_: bytes) -> str:
21
+ return hashlib.blake2b(bytes_, digest_size=8).hexdigest()
22
+
23
+
24
+ def generate_blake2b_hash_from_base64(base64_string: str) -> str:
25
+ return generate_blake2b_hash_from_bytes(base64.b64decode(base64_string))
26
+
27
+
28
+ def generate_blake2b_hash_from_string(input_string: str) -> str:
29
+ return generate_blake2b_hash_from_bytes(input_string.encode("utf-8"))
30
+
31
+
32
+ def generate_blake2b_hash_from_dict(input_dict: dict) -> str:
33
+ return generate_blake2b_hash_from_string(json.dumps(input_dict, sort_keys=True).strip())
34
+
35
+
36
+ def convert_pil_image_to_mime_data(image: PIL.Image.Image) -> MIMEData:
37
+ """Convert a PIL Image object to a MIMEData object.
38
+
39
+ Args:
40
+ image: PIL Image object to convert
41
+
42
+ Returns:
43
+ MIMEData object containing the image data
44
+ """
45
+ # Convert PIL image to base64 string
46
+ buffered = io.BytesIO()
47
+ choosen_format = image.format if (image.format and image.format.lower() in ["png", "jpeg", "gif", "webp"]) else "JPEG"
48
+ image.save(buffered, format=choosen_format)
49
+ base64_content = base64.b64encode(buffered.getvalue()).decode("utf-8")
50
+
51
+ content_hash = hashlib.sha256(base64_content.encode("utf-8")).hexdigest()
52
+
53
+ # Create MIMEData object
54
+ return MIMEData(filename=f"image_{content_hash}.{choosen_format.lower()}", url=f"data:image/{choosen_format.lower()};base64,{base64_content}")
55
+
56
+
57
+ def convert_mime_data_to_pil_image(mime_data: MIMEData) -> PIL.Image.Image:
58
+ """Convert a MIMEData object to a PIL Image object.
59
+
60
+ Args:
61
+ mime_data: MIMEData object containing image data
62
+
63
+ Returns:
64
+ PIL Image object
65
+
66
+ Raises:
67
+ ValueError: If the MIMEData object does not contain image data
68
+ """
69
+ if not mime_data.mime_type.startswith("image/"):
70
+ raise ValueError("MIMEData object does not contain image data")
71
+
72
+ # Decode base64 content to bytes
73
+ image_bytes = base64.b64decode(mime_data.content)
74
+
75
+ # Create PIL Image from bytes
76
+ image = PIL.Image.open(io.BytesIO(image_bytes))
77
+
78
+ return image
79
+
80
+
81
+ def prepare_mime_document(document: Path | str | bytes | io.IOBase | MIMEData | PIL.Image.Image | HttpUrl) -> MIMEData:
82
+ """
83
+ Convert documents (file paths or file-like objects) to MIMEData objects.
84
+
85
+ Args:
86
+ document: A path, string, bytes, or file-like object (IO[bytes])
87
+
88
+ Returns:
89
+ A MIMEData object
90
+ """
91
+ # Check if document is a HttpUrl (Pydantic type)
92
+
93
+ if isinstance(document, PIL.Image.Image):
94
+ return convert_pil_image_to_mime_data(document)
95
+
96
+ if isinstance(document, MIMEData):
97
+ return document
98
+
99
+ if isinstance(document, bytes):
100
+ # `document` is already the raw bytes
101
+ try:
102
+ extension = puremagic.from_string(document)
103
+ if extension.lower() in [".jpg", ".jpeg", ".jfif"]:
104
+ extension = ".jpeg"
105
+ except Exception:
106
+ extension = ".txt"
107
+ file_bytes = document
108
+ filename = "uploaded_file" + extension
109
+ elif isinstance(document, io.IOBase):
110
+ # `document` is a file-like object
111
+ file_bytes = document.read()
112
+ filename = getattr(document, "name", "uploaded_file")
113
+ filename = Path(filename).name
114
+ elif hasattr(document, "unicode_string") and callable(getattr(document, "unicode_string")):
115
+ with httpx.Client() as client:
116
+ url: str = document.unicode_string() # type: ignore
117
+ response = client.get(url)
118
+ response.raise_for_status()
119
+ try:
120
+ extension = puremagic.from_string(response.content)
121
+ if extension.lower() in [".jpg", ".jpeg", ".jfif"]:
122
+ extension = ".jpeg"
123
+ except Exception:
124
+ extension = ".txt"
125
+ file_bytes = response.content # Fix: Use response.content instead of document
126
+ filename = "uploaded_file" + extension
127
+ else:
128
+ # `document` is a path or a string; cast it to Path
129
+ assert isinstance(document, (Path, str))
130
+ pathdoc = Path(document)
131
+ with open(pathdoc, "rb") as f:
132
+ file_bytes = f.read()
133
+ filename = pathdoc.name
134
+
135
+ # Base64-encode
136
+ encoded_content = base64.b64encode(file_bytes).decode("utf-8")
137
+ # Compute SHA-256 hash over the *base64-encoded* content
138
+ hash_obj = hashlib.sha256(encoded_content.encode("utf-8"))
139
+ hash_obj.hexdigest()
140
+
141
+ # Guess MIME type based on file extension
142
+ guessed_type, _ = mimetypes.guess_type(filename)
143
+ mime_type = guessed_type or "application/octet-stream"
144
+ # Build and return the MIMEData object
145
+ mime_data = MIMEData(filename=filename, url=f"data:{mime_type};base64,{encoded_content}")
146
+ assert_valid_file_type(mime_data.extension) # <-- Validate extension as needed
147
+
148
+ return mime_data
149
+
150
+
151
+ def prepare_mime_document_list(documents: Sequence[Path | str | bytes | MIMEData | io.IOBase | PIL.Image.Image]) -> list[MIMEData]:
152
+ """
153
+ Convert documents (file paths or file-like objects) to MIMEData objects.
154
+
155
+ Args:
156
+ documents: List of document paths or file-like objects
157
+
158
+ Returns:
159
+ List of MIMEData objects
160
+ """
161
+ return [prepare_mime_document(doc) for doc in documents]
162
+
163
+
164
+ def assert_valid_file_type(file_extension: str) -> None:
165
+ assert "." + file_extension in get_args(SUPPORTED_TYPES), f"Invalid file type: {file_extension}. Must be one of: {get_args(SUPPORTED_TYPES)}"
@@ -0,0 +1,169 @@
1
+ import datetime
2
+ import json
3
+
4
+ from jiter import from_json
5
+ from openai.types.chat.chat_completion_content_part_image_param import ChatCompletionContentPartImageParam, ImageURL
6
+ from openai.types.chat.chat_completion_content_part_param import ChatCompletionContentPartParam
7
+ from openai.types.chat.chat_completion_content_part_text_param import ChatCompletionContentPartTextParam
8
+ from openai.types.chat.parsed_chat_completion import ParsedChatCompletionMessage
9
+ from openai.types.completion_usage import CompletionTokensDetails, CompletionUsage, PromptTokensDetails
10
+ from openai.types.responses.easy_input_message_param import EasyInputMessageParam
11
+
12
+ # from openai.types.responses.response_input_param import ResponseInputParam
13
+ from openai.types.responses.response import Response
14
+ from openai.types.responses.response_input_image_param import ResponseInputImageParam
15
+ from openai.types.responses.response_input_message_content_list_param import ResponseInputMessageContentListParam
16
+ from openai.types.responses.response_input_param import ResponseInputItemParam
17
+ from openai.types.responses.response_input_text_param import ResponseInputTextParam
18
+
19
+ from ..types.chat import ChatCompletionRetabMessage
20
+ from ..types.documents.extractions import RetabParsedChatCompletion, RetabParsedChoice
21
+
22
+
23
+ def convert_to_openai_format(messages: list[ChatCompletionRetabMessage]) -> list[ResponseInputItemParam]:
24
+ """
25
+ Converts a list of ChatCompletionRetabMessage to the OpenAI ResponseInputParam format.
26
+
27
+ Args:
28
+ messages: List of chat messages in UIForm format
29
+
30
+ Returns:
31
+ Messages in OpenAI ResponseInputParam format for the Responses API
32
+ """
33
+ formatted_messages: list[ResponseInputItemParam] = []
34
+
35
+ for message in messages:
36
+ role = message["role"]
37
+ content = message["content"]
38
+
39
+ # Handle different content formats
40
+ formatted_content: ResponseInputMessageContentListParam = []
41
+
42
+ if isinstance(content, str):
43
+ # Simple text content - provide direct string value
44
+ formatted_content.append(ResponseInputTextParam(text=content, type="input_text"))
45
+ elif isinstance(content, list):
46
+ # Content is a list of parts
47
+ for part in content:
48
+ if part["type"] == "text":
49
+ formatted_content.append(ResponseInputTextParam(text=part["text"], type="input_text"))
50
+ elif part["type"] == "image_url":
51
+ if "detail" in part["image_url"]:
52
+ detail = part["image_url"]["detail"]
53
+ else:
54
+ detail = "high"
55
+ formatted_content.append(ResponseInputImageParam(image_url=part["image_url"]["url"], type="input_image", detail=detail))
56
+ else:
57
+ print(f"Not supported content type: {part['type']}... Skipping...")
58
+
59
+ # Create Message structure which is one of the types in ResponseInputItemParam
60
+ formatted_message = EasyInputMessageParam(role=role, content=formatted_content, type="message")
61
+
62
+ formatted_messages.append(formatted_message)
63
+
64
+ return formatted_messages
65
+
66
+
67
+ def convert_from_openai_format(messages: list[ResponseInputItemParam]) -> list[ChatCompletionRetabMessage]:
68
+ """
69
+ Converts messages from OpenAI ResponseInputParam format to ChatCompletionRetabMessage format.
70
+
71
+ Args:
72
+ messages: Messages in OpenAI ResponseInputParam format
73
+
74
+ Returns:
75
+ List of chat messages in UIForm format
76
+ """
77
+ formatted_messages: list[ChatCompletionRetabMessage] = []
78
+
79
+ for message in messages:
80
+ if "role" not in message or "content" not in message:
81
+ # Mandatory fields for a message
82
+ if message.get("type") != "message":
83
+ print(f"Not supported message type: {message.get('type')}... Skipping...")
84
+ continue
85
+
86
+ role = message["role"]
87
+ content = message["content"]
88
+
89
+ if "type" not in message:
90
+ # The type is required by all other sub-types of ResponseInputItemParam except for EasyInputMessageParam and Message, which are messages.
91
+ message["type"] = "message"
92
+
93
+ role = message["role"]
94
+ content = message["content"]
95
+ formatted_content: str | list[ChatCompletionContentPartParam]
96
+
97
+ if isinstance(content, str):
98
+ formatted_content = content
99
+ else:
100
+ # Handle different content formats
101
+ formatted_content = []
102
+ for part in content:
103
+ if part["type"] == "input_text":
104
+ formatted_content.append(ChatCompletionContentPartTextParam(text=part["text"], type="text"))
105
+ elif part["type"] == "input_image":
106
+ image_url = part.get("image_url") or ""
107
+ image_detail = part.get("detail") or "high"
108
+ formatted_content.append(ChatCompletionContentPartImageParam(image_url=ImageURL(url=image_url, detail=image_detail), type="image_url"))
109
+ else:
110
+ print(f"Not supported content type: {part['type']}... Skipping...")
111
+
112
+ # Create message in UIForm format
113
+ formatted_message = ChatCompletionRetabMessage(role=role, content=formatted_content)
114
+ formatted_messages.append(formatted_message)
115
+
116
+ return formatted_messages
117
+
118
+
119
+ def parse_openai_responses_response(response: Response) -> RetabParsedChatCompletion:
120
+ """
121
+ Convert an OpenAI Response (Responses API) to RetabParsedChatCompletion type.
122
+
123
+ Args:
124
+ response: Response from OpenAI Responses API
125
+
126
+ Returns:
127
+ Parsed response in RetabParsedChatCompletion format
128
+ """
129
+ # Create the RetabParsedChatCompletion object
130
+ if response.usage:
131
+ usage = CompletionUsage(
132
+ prompt_tokens=response.usage.input_tokens,
133
+ completion_tokens=response.usage.output_tokens,
134
+ total_tokens=response.usage.total_tokens,
135
+ prompt_tokens_details=PromptTokensDetails(
136
+ cached_tokens=response.usage.input_tokens_details.cached_tokens,
137
+ ),
138
+ completion_tokens_details=CompletionTokensDetails(
139
+ reasoning_tokens=response.usage.output_tokens_details.reasoning_tokens,
140
+ ),
141
+ )
142
+ else:
143
+ usage = None
144
+
145
+ # Parse the ParsedChoice
146
+ choices = []
147
+ output_text = response.output_text
148
+ result_object = from_json(bytes(output_text, "utf-8"), partial_mode=True) # Attempt to parse the result even if EOF is reached
149
+
150
+ choices.append(
151
+ RetabParsedChoice(
152
+ index=0,
153
+ message=ParsedChatCompletionMessage(
154
+ role="assistant",
155
+ content=json.dumps(result_object),
156
+ ),
157
+ finish_reason="stop",
158
+ )
159
+ )
160
+
161
+ return RetabParsedChatCompletion(
162
+ id=response.id,
163
+ choices=choices,
164
+ created=int(datetime.datetime.now().timestamp()),
165
+ model=response.model,
166
+ object="chat.completion",
167
+ likelihoods={},
168
+ usage=usage,
169
+ )
@@ -0,0 +1,52 @@
1
+ from contextlib import AbstractAsyncContextManager, AbstractContextManager
2
+ from typing import Any, AsyncGenerator, Callable, Generator, TypeVar, Union
3
+
4
+ T = TypeVar("T")
5
+
6
+
7
+ class AsyncGeneratorContextManager(AbstractAsyncContextManager[AsyncGenerator[T, None]]):
8
+ def __init__(self, generator_func: Callable[..., AsyncGenerator[T, None]], *args: Any, **kwargs: Any):
9
+ self.generator_func = generator_func
10
+ self.args = args
11
+ self.kwargs = kwargs
12
+ self.iterator: Union[AsyncGenerator[T, None], None] = None
13
+
14
+ async def __aenter__(self) -> AsyncGenerator[T, None]:
15
+ # Create the asynchronous iterator from the generator function
16
+ self.iterator = self.generator_func(*self.args, **self.kwargs)
17
+ return self.iterator
18
+
19
+ async def __aexit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any) -> None:
20
+ # Ensure the iterator is properly closed if it supports aclose
21
+ if self.iterator is not None:
22
+ await self.iterator.aclose()
23
+
24
+
25
+ class GeneratorContextManager(AbstractContextManager[Generator[T, None, None]]):
26
+ def __init__(self, generator_func: Callable[..., Generator[T, None, None]], *args: Any, **kwargs: Any):
27
+ self.generator_func = generator_func
28
+ self.args = args
29
+ self.kwargs = kwargs
30
+ self.iterator: Union[Generator[T, None, None], None] = None
31
+
32
+ def __enter__(self) -> Generator[T, None, None]:
33
+ self.iterator = self.generator_func(*self.args, **self.kwargs)
34
+ return self.iterator
35
+
36
+ def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any) -> None:
37
+ if self.iterator is not None:
38
+ self.iterator.close()
39
+
40
+
41
+ def as_async_context_manager(func: Callable[..., AsyncGenerator[T, None]]) -> Callable[..., AsyncGeneratorContextManager[T]]:
42
+ def wrapper(*args: Any, **kwargs: Any) -> AsyncGeneratorContextManager[T]:
43
+ return AsyncGeneratorContextManager(func, *args, **kwargs)
44
+
45
+ return wrapper
46
+
47
+
48
+ def as_context_manager(func: Callable[..., Generator[T, None, None]]) -> Callable[..., GeneratorContextManager[T]]:
49
+ def wrapper(*args: Any, **kwargs: Any) -> GeneratorContextManager[T]:
50
+ return GeneratorContextManager(func, *args, **kwargs)
51
+
52
+ return wrapper
File without changes
@@ -0,0 +1,301 @@
1
+ from typing import Optional
2
+
3
+ from openai.types.completion_usage import CompletionUsage
4
+ from pydantic import BaseModel, Field
5
+
6
+ # https://platform.openai.com/docs/guides/prompt-caching
7
+ from ...types.ai_models import Amount, Pricing
8
+ from ...utils.ai_models import get_model_card
9
+
10
+ # ─── PRICING MODELS ────────────────────────────────────────────────────────────
11
+
12
+
13
+ def compute_api_call_cost(pricing: Pricing, usage: CompletionUsage, is_ft: bool = False) -> Amount:
14
+ """
15
+ Computes the price (as an Amount) for the given token usage, based on the pricing.
16
+
17
+ Assumptions / rules:
18
+ - The pricing rates are per 1,000,000 tokens.
19
+ - The usage is broken out into prompt and completion tokens.
20
+ - If details are provided, the prompt tokens are split into:
21
+ • cached text tokens (if any),
22
+ • audio tokens (if any), and
23
+ • the remaining are treated as "regular" text tokens.
24
+ - Similarly, completion tokens are split into audio tokens (if any) and the remaining text.
25
+ - For text tokens, if a cached price is not explicitly provided in pricing.text.cached,
26
+ we assume a 50% discount off the prompt price.
27
+ - (A similar rule could be applied to audio tokens – i.e. 20% of the normal price –
28
+ if cached audio tokens were provided. In our usage model, however, only text tokens
29
+ are marked as cached.)
30
+ - If is_ft is True, the price is adjusted using the ft_price_hike multiplier.
31
+ """
32
+
33
+ # ----- Process prompt tokens -----
34
+ prompt_cached_text = 0
35
+ prompt_audio = 0
36
+ if usage.prompt_tokens_details:
37
+ prompt_cached_text = usage.prompt_tokens_details.cached_tokens or 0
38
+ prompt_audio = usage.prompt_tokens_details.audio_tokens or 0
39
+
40
+ # The rest of the prompt tokens are assumed to be regular (non-cached) text tokens.
41
+ prompt_regular_text = usage.prompt_tokens - prompt_cached_text - prompt_audio
42
+
43
+ # ----- Process completion tokens -----
44
+ completion_audio = 0
45
+ if usage.completion_tokens_details:
46
+ completion_audio = usage.completion_tokens_details.audio_tokens or 0
47
+
48
+ # The rest are text tokens.
49
+ completion_regular_text = usage.completion_tokens - completion_audio
50
+
51
+ # ----- Calculate text token costs -----
52
+ # Regular prompt text tokens cost at the standard prompt rate.
53
+ cost_text_prompt = prompt_regular_text * pricing.text.prompt
54
+
55
+ # Cached text tokens: if the pricing model provides a cached rate, use it.
56
+ # Otherwise, apply a 50% discount (i.e. 0.5 * prompt price).
57
+ text_cached_price = pricing.text.prompt * pricing.text.cached_discount
58
+ cost_text_cached = prompt_cached_text * text_cached_price
59
+
60
+ # Completion text tokens cost at the standard completion rate.
61
+ cost_text_completion = completion_regular_text * pricing.text.completion
62
+
63
+ total_text_cost = cost_text_prompt + cost_text_cached + cost_text_completion
64
+
65
+ # ----- Calculate audio token costs (if any) -----
66
+ total_audio_cost = 0.0
67
+ if pricing.audio:
68
+ cost_audio_prompt = prompt_audio * pricing.audio.prompt
69
+ cost_audio_completion = completion_audio * pricing.audio.completion
70
+ total_audio_cost = cost_audio_prompt + cost_audio_completion
71
+
72
+ total_cost = (total_text_cost + total_audio_cost) / 1e6
73
+
74
+ # Apply fine-tuning price hike if applicable
75
+ if is_ft and hasattr(pricing, "ft_price_hike"):
76
+ total_cost *= pricing.ft_price_hike
77
+
78
+ return Amount(value=total_cost, currency="USD")
79
+
80
+
81
+ def compute_cost_from_model(model: str, usage: CompletionUsage) -> Amount:
82
+ # Extract base model name for fine-tuned models like "ft:gpt-4o:retab:4389573"
83
+ is_ft = False
84
+ if model.startswith("ft:"):
85
+ # Split by colon and take the second part (index 1) which contains the base model
86
+ parts = model.split(":")
87
+ if len(parts) > 1:
88
+ model = parts[1]
89
+ is_ft = True
90
+
91
+ # Use the get_model_card function from types.ai_models
92
+ try:
93
+ model_card = get_model_card(model)
94
+ pricing = model_card.pricing
95
+ except ValueError:
96
+ raise ValueError(f"No pricing information found for model: {model}")
97
+
98
+ return compute_api_call_cost(pricing, usage, is_ft)
99
+
100
+
101
+ ########################
102
+ # USELESS FOR NOW
103
+ ########################
104
+
105
+
106
+ class CompletionsUsage(BaseModel):
107
+ """The aggregated completions usage details of the specific time bucket."""
108
+
109
+ object: str = Field(default="organization.usage.completions.result", description="Type identifier for the completions usage object")
110
+ input_tokens: int = Field(
111
+ description="The aggregated number of text input tokens used, including cached tokens. For customers subscribe to scale tier, this includes scale tier tokens."
112
+ )
113
+ input_cached_tokens: int = Field(
114
+ description="The aggregated number of text input tokens that has been cached from previous requests. For customers subscribe to scale tier, this includes scale tier tokens."
115
+ )
116
+ output_tokens: int = Field(description="The aggregated number of text output tokens used. For customers subscribe to scale tier, this includes scale tier tokens.")
117
+ input_audio_tokens: int = Field(description="The aggregated number of audio input tokens used, including cached tokens.")
118
+ output_audio_tokens: int = Field(description="The aggregated number of audio output tokens used.")
119
+ num_model_requests: int = Field(description="The count of requests made to the model.")
120
+ project_id: Optional[str] = Field(default=None, description="When group_by=project_id, this field provides the project ID of the grouped usage result.")
121
+ user_id: Optional[str] = Field(default=None, description="When group_by=user_id, this field provides the user ID of the grouped usage result.")
122
+ api_key_id: Optional[str] = Field(default=None, description="When group_by=api_key_id, this field provides the API key ID of the grouped usage result.")
123
+ model: Optional[str] = Field(default=None, description="When group_by=model, this field provides the model name of the grouped usage result.")
124
+ batch: Optional[bool] = Field(default=None, description="When group_by=batch, this field tells whether the grouped usage result is batch or not.")
125
+
126
+
127
+ ########################
128
+ # DETAILED COST BREAKDOWN
129
+ ########################
130
+
131
+
132
+ class TokenCounts(BaseModel):
133
+ """Detailed breakdown of token counts by type and category."""
134
+
135
+ # Prompt token counts
136
+ prompt_regular_text: int
137
+ prompt_cached_text: int
138
+ prompt_audio: int
139
+
140
+ # Completion token counts
141
+ completion_regular_text: int
142
+ completion_audio: int
143
+
144
+ # Total tokens (should match sum of all components)
145
+ total_tokens: int
146
+
147
+
148
+ class CostBreakdown(BaseModel):
149
+ """Detailed breakdown of API call costs by token type and usage category."""
150
+
151
+ # Total cost amount
152
+ total: Amount
153
+
154
+ # Text token costs broken down by category
155
+ text_prompt_cost: Amount
156
+ text_cached_cost: Amount
157
+ text_completion_cost: Amount
158
+ text_total_cost: Amount
159
+
160
+ # Audio token costs broken down by category (if applicable)
161
+ audio_prompt_cost: Optional[Amount] = None
162
+ audio_completion_cost: Optional[Amount] = None
163
+ audio_total_cost: Optional[Amount] = None
164
+
165
+ # Token counts for reference
166
+ token_counts: TokenCounts
167
+
168
+ # Model and fine-tuning information
169
+ model: str
170
+ is_fine_tuned: bool = False
171
+
172
+
173
+ def compute_api_call_cost_with_breakdown(pricing: Pricing, usage: CompletionUsage, model: str, is_ft: bool = False) -> CostBreakdown:
174
+ """
175
+ Computes a detailed price breakdown for the given token usage, based on the pricing.
176
+
177
+ Returns a CostBreakdown object containing costs broken down by token type and category.
178
+ """
179
+ # ----- Process prompt tokens -----
180
+ prompt_cached_text = 0
181
+ prompt_audio = 0
182
+ if usage.prompt_tokens_details:
183
+ prompt_cached_text = usage.prompt_tokens_details.cached_tokens or 0
184
+ prompt_audio = usage.prompt_tokens_details.audio_tokens or 0
185
+
186
+ # The rest of the prompt tokens are assumed to be regular (non-cached) text tokens.
187
+ prompt_regular_text = usage.prompt_tokens - prompt_cached_text - prompt_audio
188
+
189
+ # ----- Process completion tokens -----
190
+ completion_audio = 0
191
+ if usage.completion_tokens_details:
192
+ completion_audio = usage.completion_tokens_details.audio_tokens or 0
193
+
194
+ # The rest are text tokens.
195
+ completion_regular_text = usage.completion_tokens - completion_audio
196
+
197
+ # ----- Calculate text token costs -----
198
+ # Regular prompt text tokens cost at the standard prompt rate.
199
+ cost_text_prompt = prompt_regular_text * pricing.text.prompt
200
+
201
+ # Cached text tokens: if the pricing model provides a cached rate, use it.
202
+ # Otherwise, apply a 50% discount (i.e. 0.5 * prompt price).
203
+ text_cached_price = pricing.text.prompt * pricing.text.cached_discount
204
+ cost_text_cached = prompt_cached_text * text_cached_price
205
+
206
+ # Completion text tokens cost at the standard completion rate.
207
+ cost_text_completion = completion_regular_text * pricing.text.completion
208
+
209
+ total_text_cost = cost_text_prompt + cost_text_cached + cost_text_completion
210
+
211
+ # ----- Calculate audio token costs (if any) -----
212
+ cost_audio_prompt = 0.0
213
+ cost_audio_completion = 0.0
214
+ total_audio_cost = 0.0
215
+
216
+ if pricing.audio and (prompt_audio > 0 or completion_audio > 0):
217
+ cost_audio_prompt = prompt_audio * pricing.audio.prompt
218
+ cost_audio_completion = completion_audio * pricing.audio.completion
219
+ total_audio_cost = cost_audio_prompt + cost_audio_completion
220
+
221
+ # Convert to dollars (divide by 1M) and create Amount objects
222
+ ft_multiplier = pricing.ft_price_hike if is_ft else 1.0
223
+
224
+ # Create Amount objects for each cost category
225
+ text_prompt_amount = Amount(value=(cost_text_prompt / 1e6) * ft_multiplier, currency="USD")
226
+ text_cached_amount = Amount(value=(cost_text_cached / 1e6) * ft_multiplier, currency="USD")
227
+ text_completion_amount = Amount(value=(cost_text_completion / 1e6) * ft_multiplier, currency="USD")
228
+ text_total_amount = Amount(value=(total_text_cost / 1e6) * ft_multiplier, currency="USD")
229
+
230
+ # Audio amounts (if applicable)
231
+ audio_prompt_amount = None
232
+ audio_completion_amount = None
233
+ audio_total_amount = None
234
+
235
+ if pricing.audio and (prompt_audio > 0 or completion_audio > 0):
236
+ audio_prompt_amount = Amount(value=(cost_audio_prompt / 1e6) * ft_multiplier, currency="USD")
237
+ audio_completion_amount = Amount(value=(cost_audio_completion / 1e6) * ft_multiplier, currency="USD")
238
+ audio_total_amount = Amount(value=(total_audio_cost / 1e6) * ft_multiplier, currency="USD")
239
+
240
+ # Total cost
241
+ total_cost = (total_text_cost + total_audio_cost) / 1e6 * ft_multiplier
242
+ total_amount = Amount(value=total_cost, currency="USD")
243
+
244
+ # Create TokenCounts object with token usage breakdown
245
+ token_counts = TokenCounts(
246
+ prompt_regular_text=prompt_regular_text,
247
+ prompt_cached_text=prompt_cached_text,
248
+ prompt_audio=prompt_audio,
249
+ completion_regular_text=completion_regular_text,
250
+ completion_audio=completion_audio,
251
+ total_tokens=usage.total_tokens,
252
+ )
253
+
254
+ return CostBreakdown(
255
+ total=total_amount,
256
+ text_prompt_cost=text_prompt_amount,
257
+ text_cached_cost=text_cached_amount,
258
+ text_completion_cost=text_completion_amount,
259
+ text_total_cost=text_total_amount,
260
+ audio_prompt_cost=audio_prompt_amount,
261
+ audio_completion_cost=audio_completion_amount,
262
+ audio_total_cost=audio_total_amount,
263
+ token_counts=token_counts,
264
+ model=model,
265
+ is_fine_tuned=is_ft,
266
+ )
267
+
268
+
269
+ def compute_cost_from_model_with_breakdown(model: str, usage: CompletionUsage) -> CostBreakdown:
270
+ """
271
+ Computes a detailed cost breakdown for an API call using the specified model and usage.
272
+
273
+ Args:
274
+ model: The model name (can be a fine-tuned model like "ft:gpt-4o:retab:4389573")
275
+ usage: Token usage statistics for the API call
276
+
277
+ Returns:
278
+ CostBreakdown object with detailed cost information
279
+
280
+ Raises:
281
+ ValueError: If no pricing information is found for the model
282
+ """
283
+ # Extract base model name for fine-tuned models like "ft:gpt-4o:retab:4389573"
284
+ original_model = model
285
+ is_ft = False
286
+
287
+ if model.startswith("ft:"):
288
+ # Split by colon and take the second part (index 1) which contains the base model
289
+ parts = model.split(":")
290
+ if len(parts) > 1:
291
+ model = parts[1]
292
+ is_ft = True
293
+
294
+ # Use the get_model_card function from types.ai_models
295
+ try:
296
+ model_card = get_model_card(model)
297
+ pricing = model_card.pricing
298
+ except ValueError:
299
+ raise ValueError(f"No pricing information found for model: {original_model}")
300
+
301
+ return compute_api_call_cost_with_breakdown(pricing, usage, original_model, is_ft)