callable-ai 0.1.5__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.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.3
2
+ Name: callable-ai
3
+ Version: 0.1.5
4
+ Summary: Callable-first AI response and tool harness
5
+ Author: Mittal Analytics Pvt Ltd
6
+ Requires-Dist: openai>=2.38,<3
7
+ Requires-Dist: pydantic>=2.12,<3
8
+ Requires-Python: >=3.12
9
+ Project-URL: Source, https://github.com/Mittal-Analytics/mittal-ai
10
+ Project-URL: Issues, https://github.com/Mittal-Analytics/mittal-ai/issues
11
+ Project-URL: Changelog, https://github.com/Mittal-Analytics/mittal-ai/blob/main/CHANGELOG.md
12
+ Description-Content-Type: text/markdown
13
+
14
+ # callable-ai
15
+
16
+ A callable-first AI harness from Mittal Analytics. Install it as `callable-ai`
17
+ and import it as `callable_ai`. It provides:
18
+
19
+ - streaming, non-streaming and structured LLM responses;
20
+ - tool-call handling, reusable tool helpers and message-history repair;
21
+ - token-cost calculation;
22
+ - OpenRouter routing and provider preferences;
23
+ - compatibility fixes for Chinese models; and
24
+ - `dj-evals` events for model requests and tool calls.
25
+
26
+ The application keeps its API keys. The model declares which provider and base URL
27
+ the harness should use:
28
+
29
+ ```python
30
+ from callable_ai import AIModel, get_client, get_structured_response
31
+ from pydantic import BaseModel
32
+
33
+
34
+ class Summary(BaseModel):
35
+ text: str
36
+
37
+
38
+ model = AIModel(
39
+ name="openai/gpt-5.4",
40
+ api_key="...",
41
+ provider="openrouter",
42
+ base_url="https://openrouter.ai/api/v1",
43
+ input_tokens_cost_usd=2.5,
44
+ input_tokens_cached_cost_usd=0.25,
45
+ output_tokens_cost_usd=15,
46
+ output_tokens_reasoning_cost_usd=15,
47
+ )
48
+
49
+ async with get_client(model) as client:
50
+ async for event in get_structured_response(
51
+ client=client,
52
+ ai_model=model,
53
+ input=[{"role": "user", "content": "Summarise this."}],
54
+ tools=[],
55
+ text_format=Summary,
56
+ reasoning_effort="low",
57
+ ):
58
+ print(event)
59
+ ```
60
+
61
+ The main public entry points are `get_response`, `get_streaming_response` and
62
+ `get_structured_response`.
63
+
64
+ ## Tool helpers
65
+
66
+ Use `format_docstring` to customize a reusable tool description and
67
+ `partial_with_doc` to bind arguments that should not be exposed to the model:
68
+
69
+ ```python
70
+ from callable_ai import (
71
+ ToolCallResult,
72
+ format_docstring,
73
+ get_streaming_response,
74
+ partial_with_doc,
75
+ )
76
+
77
+
78
+ @format_docstring(entity="company")
79
+ def answer_question(_company_id: int, question: str) -> ToolCallResult:
80
+ """Answer a question about a {entity}.
81
+
82
+ Args:
83
+ - question: Question asked by the user.
84
+ """
85
+ return {"content": f"{_company_id}: {question}"}
86
+
87
+
88
+ company_tool = partial_with_doc(answer_question, _company_id=123)
89
+
90
+ async for event in get_streaming_response(
91
+ user="user-123",
92
+ ai_model=model,
93
+ messages=[{"role": "user", "content": "What changed?"}],
94
+ tools=[company_tool],
95
+ reasoning_effort="low",
96
+ ):
97
+ print(event)
98
+ ```
99
+
100
+ `company_tool` exposes only `question`; its bound company ID stays private. Tools
101
+ return `ToolCallResult`; async generators may yield `EvalEvent` updates first.
102
+
103
+ ## Publishing a new version
104
+
105
+ The [release workflow](.github/workflows/release.yml) publishes version tags to
106
+ PyPI using trusted publishing. Configure `callable-ai` as a trusted publisher on
107
+ PyPI before its first release. From a clean working tree, publish the next patch
108
+ release with:
109
+
110
+ ```bash
111
+ uv version --bump patch
112
+ version=$(uv version --short)
113
+
114
+ uv run pytest
115
+ uv run pre-commit run --all-files
116
+
117
+ git add pyproject.toml uv.lock
118
+ git commit -m "Release version $version"
119
+ git tag -a "v$version" -m "v$version"
120
+ git push origin main "v$version"
121
+ ```
122
+
123
+ The tag must match the version in `pyproject.toml`, with a `v` prefix. Use
124
+ `--bump minor` or `--bump major` when appropriate. PyPI versions are immutable,
125
+ so every release needs a new version.
@@ -0,0 +1,112 @@
1
+ # callable-ai
2
+
3
+ A callable-first AI harness from Mittal Analytics. Install it as `callable-ai`
4
+ and import it as `callable_ai`. It provides:
5
+
6
+ - streaming, non-streaming and structured LLM responses;
7
+ - tool-call handling, reusable tool helpers and message-history repair;
8
+ - token-cost calculation;
9
+ - OpenRouter routing and provider preferences;
10
+ - compatibility fixes for Chinese models; and
11
+ - `dj-evals` events for model requests and tool calls.
12
+
13
+ The application keeps its API keys. The model declares which provider and base URL
14
+ the harness should use:
15
+
16
+ ```python
17
+ from callable_ai import AIModel, get_client, get_structured_response
18
+ from pydantic import BaseModel
19
+
20
+
21
+ class Summary(BaseModel):
22
+ text: str
23
+
24
+
25
+ model = AIModel(
26
+ name="openai/gpt-5.4",
27
+ api_key="...",
28
+ provider="openrouter",
29
+ base_url="https://openrouter.ai/api/v1",
30
+ input_tokens_cost_usd=2.5,
31
+ input_tokens_cached_cost_usd=0.25,
32
+ output_tokens_cost_usd=15,
33
+ output_tokens_reasoning_cost_usd=15,
34
+ )
35
+
36
+ async with get_client(model) as client:
37
+ async for event in get_structured_response(
38
+ client=client,
39
+ ai_model=model,
40
+ input=[{"role": "user", "content": "Summarise this."}],
41
+ tools=[],
42
+ text_format=Summary,
43
+ reasoning_effort="low",
44
+ ):
45
+ print(event)
46
+ ```
47
+
48
+ The main public entry points are `get_response`, `get_streaming_response` and
49
+ `get_structured_response`.
50
+
51
+ ## Tool helpers
52
+
53
+ Use `format_docstring` to customize a reusable tool description and
54
+ `partial_with_doc` to bind arguments that should not be exposed to the model:
55
+
56
+ ```python
57
+ from callable_ai import (
58
+ ToolCallResult,
59
+ format_docstring,
60
+ get_streaming_response,
61
+ partial_with_doc,
62
+ )
63
+
64
+
65
+ @format_docstring(entity="company")
66
+ def answer_question(_company_id: int, question: str) -> ToolCallResult:
67
+ """Answer a question about a {entity}.
68
+
69
+ Args:
70
+ - question: Question asked by the user.
71
+ """
72
+ return {"content": f"{_company_id}: {question}"}
73
+
74
+
75
+ company_tool = partial_with_doc(answer_question, _company_id=123)
76
+
77
+ async for event in get_streaming_response(
78
+ user="user-123",
79
+ ai_model=model,
80
+ messages=[{"role": "user", "content": "What changed?"}],
81
+ tools=[company_tool],
82
+ reasoning_effort="low",
83
+ ):
84
+ print(event)
85
+ ```
86
+
87
+ `company_tool` exposes only `question`; its bound company ID stays private. Tools
88
+ return `ToolCallResult`; async generators may yield `EvalEvent` updates first.
89
+
90
+ ## Publishing a new version
91
+
92
+ The [release workflow](.github/workflows/release.yml) publishes version tags to
93
+ PyPI using trusted publishing. Configure `callable-ai` as a trusted publisher on
94
+ PyPI before its first release. From a clean working tree, publish the next patch
95
+ release with:
96
+
97
+ ```bash
98
+ uv version --bump patch
99
+ version=$(uv version --short)
100
+
101
+ uv run pytest
102
+ uv run pre-commit run --all-files
103
+
104
+ git add pyproject.toml uv.lock
105
+ git commit -m "Release version $version"
106
+ git tag -a "v$version" -m "v$version"
107
+ git push origin main "v$version"
108
+ ```
109
+
110
+ The tag must match the version in `pyproject.toml`, with a `v` prefix. Use
111
+ `--bump minor` or `--bump major` when appropriate. PyPI versions are immutable,
112
+ so every release needs a new version.
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "callable-ai"
3
+ version = "0.1.5"
4
+ description = "Callable-first AI response and tool harness"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "openai>=2.38,<3",
9
+ "pydantic>=2.12,<3",
10
+ ]
11
+
12
+ [[project.authors]]
13
+ name = "Mittal Analytics Pvt Ltd"
14
+
15
+ [project.urls]
16
+ Source = "https://github.com/Mittal-Analytics/mittal-ai"
17
+ Issues = "https://github.com/Mittal-Analytics/mittal-ai/issues"
18
+ Changelog = "https://github.com/Mittal-Analytics/mittal-ai/blob/main/CHANGELOG.md"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.9.26,<0.13.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.uv.build-backend]
25
+ module-name = "callable_ai"
26
+
27
+ [tool.pytest.ini_options]
28
+ asyncio_mode = "auto"
29
+ pythonpath = ["src"]
30
+
31
+ [tool.ruff]
32
+ line-length = 88
33
+
34
+ [tool.ruff.lint]
35
+ select = [
36
+ "E",
37
+ "F",
38
+ "I",
39
+ ]
40
+ ignore = ["E501"]
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "pre-commit>=4.6.2",
45
+ "pytest",
46
+ "pytest-asyncio",
47
+ ]
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "callable-ai"
3
+ version = "0.1.5"
4
+ description = "Callable-first AI response and tool harness"
5
+ readme = "README.md"
6
+ authors = [{ name = "Mittal Analytics Pvt Ltd" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "openai>=2.38,<3",
10
+ "pydantic>=2.12,<3",
11
+ ]
12
+
13
+ [project.urls]
14
+ Source = "https://github.com/Mittal-Analytics/mittal-ai"
15
+ Issues = "https://github.com/Mittal-Analytics/mittal-ai/issues"
16
+ Changelog = "https://github.com/Mittal-Analytics/mittal-ai/blob/main/CHANGELOG.md"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.9.26,<0.13.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [tool.uv.build-backend]
23
+ module-name = "callable_ai"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "pre-commit>=4.6.2",
28
+ "pytest",
29
+ "pytest-asyncio",
30
+ ]
31
+
32
+ [tool.pytest.ini_options]
33
+ asyncio_mode = "auto"
34
+ pythonpath = ["src"]
35
+
36
+ [tool.ruff]
37
+ line-length = 88
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I"]
41
+ ignore = ["E501"]
@@ -0,0 +1,41 @@
1
+ from callable_ai.costing import get_abs_cost, parse_responses_usage
2
+ from callable_ai.models import AIModel
3
+ from callable_ai.responses import (
4
+ AnnotationToolCallURL,
5
+ EvalEvent,
6
+ Messages,
7
+ StreamingResponseChunk,
8
+ ToolCallResult,
9
+ ToolFunction,
10
+ UsageDetails,
11
+ gen_error,
12
+ get_client,
13
+ get_model_options,
14
+ get_response,
15
+ get_streaming_response,
16
+ get_structured_response,
17
+ repair_message_history,
18
+ )
19
+ from callable_ai.tools import format_docstring, partial_with_doc
20
+
21
+ __all__ = [
22
+ "AIModel",
23
+ "AnnotationToolCallURL",
24
+ "EvalEvent",
25
+ "Messages",
26
+ "StreamingResponseChunk",
27
+ "ToolCallResult",
28
+ "ToolFunction",
29
+ "UsageDetails",
30
+ "format_docstring",
31
+ "gen_error",
32
+ "get_abs_cost",
33
+ "get_client",
34
+ "get_model_options",
35
+ "get_response",
36
+ "get_streaming_response",
37
+ "get_structured_response",
38
+ "parse_responses_usage",
39
+ "partial_with_doc",
40
+ "repair_message_history",
41
+ ]
@@ -0,0 +1,86 @@
1
+ import json
2
+ from typing import TypeVar
3
+
4
+ from openai.types.responses import ParsedResponse, Response, ResponseInputParam
5
+ from openai.types.responses.parsed_response import (
6
+ ParsedResponseOutputMessage,
7
+ ParsedResponseOutputText,
8
+ )
9
+ from pydantic import BaseModel
10
+
11
+ from callable_ai.models import AIModel
12
+
13
+ # Keeps the parsed response type tied to the Pydantic schema passed in.
14
+ PydanticModel = TypeVar("PydanticModel", bound=BaseModel)
15
+
16
+
17
+ def supports_structured_output_with_tools(ai_model: AIModel) -> bool:
18
+ name = ai_model.name.lower()
19
+ if any(model in name for model in ["deepseek", "minimax"]):
20
+ return False
21
+ if "/" not in name:
22
+ return True
23
+ return any(model in name for model in ["gemini", "gpt", "grok"])
24
+
25
+
26
+ def add_json_schema_to_input(input: ResponseInputParam, text_format: type[BaseModel]):
27
+ schema = json.dumps(text_format.model_json_schema(), indent=2)
28
+ input.append(
29
+ {
30
+ # User role is widely supported by OpenAI-compatible providers.
31
+ "role": "user",
32
+ "content": "Return only valid JSON matching this schema:"
33
+ f"\n```json\n{schema}\n```",
34
+ }
35
+ )
36
+
37
+
38
+ def parse_response_text(
39
+ response: Response, text_format: type[PydanticModel]
40
+ ) -> ParsedResponse[PydanticModel]:
41
+ """Build ParsedResponse for providers that return JSON as plain text."""
42
+ parsed = text_format.model_validate_json(get_json_text(response.output_text))
43
+
44
+ # ParsedResponse.output_parsed reads this `parsed` field internally.
45
+ parsed_text = ParsedResponseOutputText[PydanticModel](
46
+ annotations=[],
47
+ text=response.output_text,
48
+ type="output_text",
49
+ parsed=parsed,
50
+ )
51
+
52
+ # Keep the same shape as OpenAI's parsed message response.
53
+ parsed_message = ParsedResponseOutputMessage[PydanticModel](
54
+ id=response.id,
55
+ content=[parsed_text],
56
+ role="assistant",
57
+ status="completed",
58
+ type="message",
59
+ )
60
+
61
+ # Reuse all original response fields but replace output with parsed output.
62
+ data = response.model_dump()
63
+ data["output"] = [parsed_message]
64
+ return ParsedResponse[PydanticModel].model_construct(**data)
65
+
66
+
67
+ def get_json_text(text: str) -> str:
68
+ text = text.strip()
69
+ if "```" in text:
70
+ for block in text.split("```"):
71
+ block = block.strip()
72
+ if block.lower().startswith("json"):
73
+ return block[4:].strip()
74
+ if block.startswith(("{", "[")):
75
+ return block
76
+
77
+ object_start = text.find("{")
78
+ object_end = text.rfind("}")
79
+ if object_start >= 0 and object_end >= object_start:
80
+ return text[object_start : object_end + 1]
81
+
82
+ array_start = text.find("[")
83
+ array_end = text.rfind("]")
84
+ if array_start >= 0 and array_end >= array_start:
85
+ return text[array_start : array_end + 1]
86
+ return text
@@ -0,0 +1,68 @@
1
+ from openai.types.responses.response_usage import ResponseUsage
2
+
3
+ from callable_ai.models import AIModel
4
+ from callable_ai.openrouter import OpenRouterCompletionUsage
5
+
6
+
7
+ def parse_responses_usage(responses_usage: ResponseUsage) -> OpenRouterCompletionUsage:
8
+ """
9
+ Convert OpenAI Responses API usage format to OpenRouter completion usage format.
10
+ """
11
+ openrouter_usage: OpenRouterCompletionUsage = {
12
+ "prompt_tokens": responses_usage.input_tokens,
13
+ "prompt_tokens_details": {
14
+ "cached_tokens": getattr(
15
+ responses_usage.input_tokens_details, "cached_tokens", 0
16
+ )
17
+ or 0
18
+ },
19
+ "completion_tokens": responses_usage.output_tokens,
20
+ "completion_tokens_details": {
21
+ "reasoning_tokens": getattr(
22
+ responses_usage.output_tokens_details, "reasoning_tokens", 0
23
+ )
24
+ or 0
25
+ },
26
+ "total_tokens": responses_usage.total_tokens,
27
+ }
28
+ return openrouter_usage
29
+
30
+
31
+ def get_abs_cost(
32
+ usage: OpenRouterCompletionUsage,
33
+ ai_model: AIModel,
34
+ *,
35
+ number_of_web_searches=0,
36
+ ) -> float:
37
+ # https://platform.openai.com/docs/api-reference/chat-streaming/streaming#chat-streaming/streaming-usage
38
+ input_tokens = usage["prompt_tokens"]
39
+ prompt_details = usage.get("prompt_tokens_details") or {}
40
+ input_tokens_cached = prompt_details.get("cached_tokens") or 0
41
+ output_tokens = usage["completion_tokens"]
42
+ completion_details = usage.get("completion_tokens_details") or {}
43
+ output_tokens_reasoning = completion_details.get("reasoning_tokens") or 0
44
+
45
+ input_fresh_tokens = input_tokens - input_tokens_cached
46
+ output_answer_tokens = output_tokens - output_tokens_reasoning
47
+
48
+ if ai_model.lower_token_count_cost:
49
+ cutoff, lower_token_ai_model = ai_model.lower_token_count_cost
50
+ if input_tokens <= cutoff:
51
+ ai_model = lower_token_ai_model
52
+
53
+ if number_of_web_searches:
54
+ web_search_cost = number_of_web_searches * ai_model.web_search_cost_inr / 1000
55
+ else:
56
+ web_search_cost = 0
57
+
58
+ # Calculate cost in INR/per million tokens
59
+ cost = (
60
+ (
61
+ input_fresh_tokens * ai_model.input_tokens_cost_inr
62
+ + output_answer_tokens * ai_model.output_tokens_cost_inr
63
+ + input_tokens_cached * ai_model.input_tokens_cached_cost_inr
64
+ + output_tokens_reasoning * ai_model.output_tokens_reasoning_cost_inr
65
+ )
66
+ / 1_000_000
67
+ ) + web_search_cost
68
+ return abs(cost)
@@ -0,0 +1,46 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ USD_TO_INR_RATE = 110
4
+
5
+
6
+ @dataclass
7
+ class AIModel:
8
+ name: str
9
+ api_key: str = field(repr=False, compare=False)
10
+
11
+ input_tokens_cost_usd: float
12
+ input_tokens_cached_cost_usd: float
13
+ output_tokens_cost_usd: float
14
+ output_tokens_reasoning_cost_usd: float
15
+
16
+ provider: str = "openai"
17
+ base_url: str | None = None
18
+ extra_headers: dict[str, str] | None = field(default=None, repr=False)
19
+ web_search_cost_usd: float | None = None
20
+ lower_token_count_cost: tuple[int, "AIModel"] | None = None
21
+ openrouter_providers: list[str] | None = None
22
+ usd_to_inr_rate: float = USD_TO_INR_RATE
23
+
24
+ @property
25
+ def web_search_cost_inr(self) -> float | None:
26
+ return (
27
+ self.usd_to_inr_rate * self.web_search_cost_usd
28
+ if self.web_search_cost_usd is not None
29
+ else None
30
+ )
31
+
32
+ @property
33
+ def input_tokens_cost_inr(self) -> float:
34
+ return self.usd_to_inr_rate * self.input_tokens_cost_usd
35
+
36
+ @property
37
+ def input_tokens_cached_cost_inr(self) -> float:
38
+ return self.usd_to_inr_rate * self.input_tokens_cached_cost_usd
39
+
40
+ @property
41
+ def output_tokens_cost_inr(self) -> float:
42
+ return self.usd_to_inr_rate * self.output_tokens_cost_usd
43
+
44
+ @property
45
+ def output_tokens_reasoning_cost_inr(self) -> float:
46
+ return self.usd_to_inr_rate * self.output_tokens_reasoning_cost_usd
@@ -0,0 +1,94 @@
1
+ from typing import List, Literal, NotRequired, Optional, TypeAlias, TypedDict, Union
2
+
3
+ from openai.types.chat import ChatCompletionChunk
4
+ from openai.types.chat.chat_completion import Choice
5
+ from openai.types.chat.chat_completion_chunk import ChoiceDelta
6
+
7
+
8
+ class ReasoningDetailBase(TypedDict):
9
+ id: Optional[str]
10
+ format: Literal["unknown", "openai-responses-v1", "anthropic-claude-v1"]
11
+ index: Optional[int]
12
+
13
+
14
+ class ReasoningDetailSummaryType(ReasoningDetailBase):
15
+ type: Literal["reasoning.summary"]
16
+ summary: str
17
+
18
+
19
+ class ReasoningDetailEncryptedType(ReasoningDetailBase):
20
+ type: Literal["reasoning.encrypted"]
21
+ data: str
22
+
23
+
24
+ class ReasoningDetailTextType(ReasoningDetailBase):
25
+ type: Literal["reasoning.text"]
26
+ text: str
27
+ signature: NotRequired[Optional[str]]
28
+
29
+
30
+ OpenRouterReasoningDetail: TypeAlias = Union[
31
+ ReasoningDetailSummaryType,
32
+ ReasoningDetailEncryptedType,
33
+ ReasoningDetailTextType,
34
+ ]
35
+
36
+
37
+ class OpenRouterDelta(ChoiceDelta):
38
+ # ChoiceDelta(content='', function_call=None, refusal=None, role='assistant', tool_calls=None, reasoning='**Explain', reasoning_details=[{'type': 'reasoning.summary', 'summary': '**Explain', 'format': 'openai-responses-v1', 'index': 0}])
39
+ reasoning_details: Optional[List[OpenRouterReasoningDetail]]
40
+
41
+
42
+ class OpenRouterChoice(Choice):
43
+ delta: OpenRouterDelta
44
+
45
+
46
+ class OpenRouterChatCompletionChunk(ChatCompletionChunk):
47
+ choices: List[OpenRouterChoice]
48
+
49
+
50
+ class OpenRouterCompletionTokensDetails(TypedDict, total=False):
51
+ accepted_prediction_tokens: Optional[int]
52
+ """
53
+ When using Predicted Outputs, the number of tokens in the prediction that
54
+ appeared in the completion.
55
+ """
56
+
57
+ audio_tokens: Optional[int]
58
+ """Audio input tokens generated by the model."""
59
+
60
+ reasoning_tokens: Optional[int]
61
+ """Tokens generated by the model for reasoning."""
62
+
63
+ rejected_prediction_tokens: Optional[int]
64
+ """
65
+ When using Predicted Outputs, the number of tokens in the prediction that did
66
+ not appear in the completion. However, like reasoning tokens, these tokens are
67
+ still counted in the total completion tokens for purposes of billing, output,
68
+ and context window limits.
69
+ """
70
+
71
+
72
+ class OpenRouterPromptTokensDetails(TypedDict, total=False):
73
+ audio_tokens: Optional[int]
74
+ """Audio input tokens present in the prompt."""
75
+
76
+ cached_tokens: Optional[int]
77
+ """Cached tokens present in the prompt."""
78
+
79
+
80
+ class OpenRouterCompletionUsage(TypedDict):
81
+ completion_tokens: int
82
+ """Number of tokens in the generated completion."""
83
+
84
+ prompt_tokens: int
85
+ """Number of tokens in the prompt."""
86
+
87
+ total_tokens: int
88
+ """Total number of tokens used in the request (prompt + completion)."""
89
+
90
+ completion_tokens_details: NotRequired[Optional[OpenRouterCompletionTokensDetails]]
91
+ """Breakdown of tokens used in a completion."""
92
+
93
+ prompt_tokens_details: NotRequired[Optional[OpenRouterPromptTokensDetails]]
94
+ """Breakdown of tokens used in the prompt."""
File without changes