retab 0.0.42__py3-none-any.whl → 0.0.43__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.
- retab/__init__.py +2 -1
- retab/client.py +16 -45
- retab/resources/consensus/client.py +1 -1
- retab/resources/consensus/responses.py +1 -1
- retab/resources/documents/client.py +94 -68
- retab/resources/documents/extractions.py +55 -46
- retab/resources/evaluations/client.py +32 -19
- retab/resources/evaluations/documents.py +12 -11
- retab/resources/evaluations/iterations.py +48 -30
- retab/resources/jsonlUtils.py +3 -4
- retab/resources/processors/automations/endpoints.py +49 -39
- retab/resources/processors/automations/links.py +52 -43
- retab/resources/processors/automations/mailboxes.py +74 -59
- retab/resources/processors/automations/outlook.py +104 -82
- retab/resources/processors/client.py +35 -30
- retab/resources/usage.py +2 -0
- retab/types/ai_models.py +1 -1
- retab/types/deprecated_evals.py +195 -0
- retab/types/evaluations/__init__.py +5 -2
- retab/types/evaluations/iterations.py +9 -43
- retab/types/evaluations/model.py +20 -22
- retab/types/extractions.py +1 -0
- retab/types/logs.py +5 -6
- retab/types/mime.py +1 -10
- retab/types/schemas/enhance.py +22 -5
- retab/types/schemas/evaluate.py +1 -1
- retab/types/schemas/object.py +26 -0
- retab/types/standards.py +2 -2
- retab/utils/__init__.py +3 -0
- retab/utils/ai_models.py +127 -12
- retab/utils/hashing.py +24 -0
- retab/utils/json_schema.py +1 -26
- retab/utils/mime.py +0 -17
- {retab-0.0.42.dist-info → retab-0.0.43.dist-info}/METADATA +3 -5
- {retab-0.0.42.dist-info → retab-0.0.43.dist-info}/RECORD +37 -51
- retab/_utils/__init__.py +0 -0
- retab/_utils/_model_cards/anthropic.yaml +0 -59
- retab/_utils/_model_cards/auto.yaml +0 -43
- retab/_utils/_model_cards/gemini.yaml +0 -117
- retab/_utils/_model_cards/openai.yaml +0 -301
- retab/_utils/_model_cards/xai.yaml +0 -28
- retab/_utils/ai_models.py +0 -138
- retab/_utils/benchmarking.py +0 -484
- retab/_utils/chat.py +0 -327
- retab/_utils/display.py +0 -440
- retab/_utils/json_schema.py +0 -2156
- retab/_utils/mime.py +0 -165
- retab/_utils/responses.py +0 -169
- retab/_utils/stream_context_managers.py +0 -52
- retab/_utils/usage/__init__.py +0 -0
- retab/_utils/usage/usage.py +0 -301
- {retab-0.0.42.dist-info → retab-0.0.43.dist-info}/WHEEL +0 -0
- {retab-0.0.42.dist-info → retab-0.0.43.dist-info}/top_level.txt +0 -0
retab/_utils/mime.py
DELETED
@@ -1,165 +0,0 @@
|
|
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)}"
|
retab/_utils/responses.py
DELETED
@@ -1,169 +0,0 @@
|
|
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
|
-
)
|
@@ -1,52 +0,0 @@
|
|
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
|
retab/_utils/usage/__init__.py
DELETED
File without changes
|
retab/_utils/usage/usage.py
DELETED
@@ -1,301 +0,0 @@
|
|
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)
|
File without changes
|