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.
- callable_ai-0.1.5/PKG-INFO +125 -0
- callable_ai-0.1.5/README.md +112 -0
- callable_ai-0.1.5/pyproject.toml +47 -0
- callable_ai-0.1.5/pyproject.toml.orig +41 -0
- callable_ai-0.1.5/src/callable_ai/__init__.py +41 -0
- callable_ai-0.1.5/src/callable_ai/compatibility.py +86 -0
- callable_ai-0.1.5/src/callable_ai/costing.py +68 -0
- callable_ai-0.1.5/src/callable_ai/models.py +46 -0
- callable_ai-0.1.5/src/callable_ai/openrouter.py +94 -0
- callable_ai-0.1.5/src/callable_ai/py.typed +0 -0
- callable_ai-0.1.5/src/callable_ai/responses.py +1081 -0
- callable_ai-0.1.5/src/callable_ai/tools.py +36 -0
|
@@ -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
|