dot-inference 2.0.2__tar.gz → 2.1.0__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.
- {dot_inference-2.0.2 → dot_inference-2.1.0}/.gitlab-ci.yml +49 -2
- {dot_inference-2.0.2 → dot_inference-2.1.0}/CHANGELOG.md +39 -1
- {dot_inference-2.0.2 → dot_inference-2.1.0}/PKG-INFO +2 -2
- {dot_inference-2.0.2 → dot_inference-2.1.0}/README.md +1 -1
- {dot_inference-2.0.2 → dot_inference-2.1.0}/docs/ARCHITECTURE.md +1 -1
- {dot_inference-2.0.2 → dot_inference-2.1.0}/docs/DEVELOPMENT.md +22 -4
- {dot_inference-2.0.2 → dot_inference-2.1.0}/docs/VERSIONING.md +3 -3
- {dot_inference-2.0.2 → dot_inference-2.1.0}/pyproject.toml +4 -1
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_client.py +81 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_mistral_compat.py +27 -1
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_openai_compat.py +68 -17
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_types.py +4 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/models.yaml +10 -6
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_mistral_compat.py +19 -0
- dot_inference-2.1.0/tests/test_mistral_live.py +249 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_openai_compat.py +296 -7
- {dot_inference-2.0.2 → dot_inference-2.1.0}/uv.lock +1 -1
- dot_inference-2.0.2/tests/test_mistral_live.py +0 -142
- {dot_inference-2.0.2 → dot_inference-2.1.0}/.env.template +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/.gitignore +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/.pre-commit-config.yaml +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/.python-version +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/AUTHORS.md +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/CONTRIBUTING.md +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/DCO +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/LICENSE.md +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/docs/PUBLISHING.md +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/examples/demo.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/examples/demo_streaming.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/examples/doc_stream.md +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/scripts/test_all_models.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/README.md +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/__init__.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_cost_tracker.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_llm_registry.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_local_check.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_rate_limit.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_retry.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_sync.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/mock.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/settings.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/__init__.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_cost_tracker.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_factories.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_llm_registry.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_local_check.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_rate_limit.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_retry.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_settings.py +0 -0
- {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_types.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Pipeline for a Python library published to PyPI.
|
|
2
2
|
#
|
|
3
3
|
# quality lint, format and type checks (pre-commit)
|
|
4
|
-
# test pytest on every supported Python version
|
|
4
|
+
# test pytest on every supported Python version, plus live API smoke
|
|
5
|
+
# tests when the provider code or the lockfile changes
|
|
5
6
|
# publish tag-only: build, then TestPyPI, then PyPI behind a manual gate
|
|
6
7
|
#
|
|
7
8
|
# Publishing uses PyPI Trusted Publishing (OIDC): GitLab mints a short-lived ID
|
|
@@ -71,7 +72,8 @@ test:
|
|
|
71
72
|
before_script:
|
|
72
73
|
- uv sync --all-extras --all-groups --python "$PYTHON_VERSION"
|
|
73
74
|
script:
|
|
74
|
-
|
|
75
|
+
# Live API tests have their own job below; keep this one offline.
|
|
76
|
+
- uv run --python "$PYTHON_VERSION" pytest -m "not live" --cov=src --cov-report=term --cov-report=xml
|
|
75
77
|
coverage: '/^TOTAL.*\s+(\d+%)$/'
|
|
76
78
|
artifacts:
|
|
77
79
|
when: always
|
|
@@ -80,6 +82,51 @@ test:
|
|
|
80
82
|
coverage_format: cobertura
|
|
81
83
|
path: coverage.xml
|
|
82
84
|
|
|
85
|
+
# The offline suite stubs SDK responses, so it cannot notice the real API or
|
|
86
|
+
# SDK changing shape. This job calls the real Mistral API on Mistral Small 4
|
|
87
|
+
# with tiny prompts (~$0.0005 a run), only when something it guards changed;
|
|
88
|
+
# otherwise it stays available as a manual job.
|
|
89
|
+
#
|
|
90
|
+
# Needs MISTRAL_API_KEY under Settings > CI/CD > Variables: masked, NOT
|
|
91
|
+
# protected (MR pipelines from unprotected branches would not see it). Use a
|
|
92
|
+
# dedicated key from a workspace with a low spending cap — any developer can
|
|
93
|
+
# read an unprotected variable from their branch.
|
|
94
|
+
test_live_mistral:
|
|
95
|
+
stage: test
|
|
96
|
+
before_script:
|
|
97
|
+
- uv sync --all-extras --all-groups
|
|
98
|
+
script:
|
|
99
|
+
- |
|
|
100
|
+
if [ -z "$MISTRAL_API_KEY" ]; then
|
|
101
|
+
echo "MISTRAL_API_KEY is not set. Add it under Settings > CI/CD > Variables"
|
|
102
|
+
echo "(masked, not protected) — see the comment on this job."
|
|
103
|
+
exit 1
|
|
104
|
+
fi
|
|
105
|
+
- uv run pytest -m live -v
|
|
106
|
+
retry:
|
|
107
|
+
max: 1
|
|
108
|
+
when: script_failure
|
|
109
|
+
rules:
|
|
110
|
+
# Fork MRs never get the project's key: a maintainer runs the pipeline
|
|
111
|
+
# for the MR in the parent project instead.
|
|
112
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH != $CI_MERGE_REQUEST_PROJECT_PATH
|
|
113
|
+
when: never
|
|
114
|
+
- changes:
|
|
115
|
+
- src/dot_inference/_mistral_compat.py
|
|
116
|
+
- src/dot_inference/_client.py
|
|
117
|
+
- src/dot_inference/_types.py
|
|
118
|
+
- src/dot_inference/_cost_tracker.py
|
|
119
|
+
- src/dot_inference/_retry.py
|
|
120
|
+
- src/dot_inference/_llm_registry.py
|
|
121
|
+
- src/dot_inference/models.yaml
|
|
122
|
+
- tests/test_mistral_live.py
|
|
123
|
+
# SDK upgrades are the main source of silent breakage.
|
|
124
|
+
- pyproject.toml
|
|
125
|
+
- uv.lock
|
|
126
|
+
- .gitlab-ci.yml
|
|
127
|
+
- when: manual
|
|
128
|
+
allow_failure: true
|
|
129
|
+
|
|
83
130
|
# --------------------------------------------------------------------------
|
|
84
131
|
# publish (release tags only)
|
|
85
132
|
# --------------------------------------------------------------------------
|
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.1.0] - 2026-10-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `LLMResponse.finish_reason`: why the model stopped, as the provider reports
|
|
15
|
+
it (`stop`, `tool_calls`, `length`, `content_filter`, `error`…), for both
|
|
16
|
+
`call()` and `stream()`, OpenAI-compatible and Mistral clients.
|
|
17
|
+
- Every LLM response is logged once with its `finish_reason` and body —
|
|
18
|
+
`response from '<model>': finish_reason=… id=… body=…`. `call()` logs the
|
|
19
|
+
raw response; `stream()` the assembled content and tool calls, the chunk
|
|
20
|
+
count and the usage — plus, on OpenRouter, `native_finish_reason`. DEBUG when the model ended on its own (`stop`,
|
|
21
|
+
`tool_calls`), WARNING otherwise — including a stream that ends without any
|
|
22
|
+
finish reason, which previously left no trace.
|
|
23
|
+
- An abnormal response's line also summarizes the request that produced it,
|
|
24
|
+
without its content: parameters (`extra_body` included), tool names, each
|
|
25
|
+
message's role, size, cache markers and tool calls, and its total size. Its `id` is the provider's id for the call — OpenRouter's
|
|
26
|
+
`x-generation-id` header for streams (present even when no chunk arrives),
|
|
27
|
+
the response id otherwise.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `mistral/mistral-small-latest` and `mistral/mistral-small-2603` (Mistral
|
|
32
|
+
Small 4) are now declared with `supports_reasoning: true`, accepting
|
|
33
|
+
`reasoning_effort` `none` (the default, matching the API's) or `high`. As
|
|
34
|
+
for every reasoning model, `temperature` is no longer forwarded for them.
|
|
35
|
+
|
|
36
|
+
## [2.0.3] - 2026-09-30
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- `LLMResponse.cost` and `StreamUsage.cost` no longer overstate calls that hit
|
|
41
|
+
the prompt cache: they are now the cost the provider bills when it reports
|
|
42
|
+
one (OpenRouter's `usage.cost`), instead of an estimate from the registry's
|
|
43
|
+
per-token prices that charged cached tokens at the full rate. The estimate
|
|
44
|
+
remains the fallback for providers that do not report a cost.
|
|
45
|
+
|
|
10
46
|
## [2.0.2] - 2026-08-27
|
|
11
47
|
|
|
12
48
|
### Fixed
|
|
@@ -62,6 +98,8 @@ this release.
|
|
|
62
98
|
- `LLMCallable` and `EmbeddingCallable` return a `Coroutine` rather than the
|
|
63
99
|
looser `Awaitable`, matching what `asyncio.run_coroutine_threadsafe` accepts.
|
|
64
100
|
|
|
65
|
-
[Unreleased]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/compare/v2.0
|
|
101
|
+
[Unreleased]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/compare/v2.1.0...main
|
|
102
|
+
[2.1.0]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.1.0
|
|
103
|
+
[2.0.3]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.0.3
|
|
66
104
|
[2.0.2]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.0.2
|
|
67
105
|
[2.0.0]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dot-inference
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Unified async Python library for LLM inference and embeddings across multiple providers
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference
|
|
6
6
|
Project-URL: Repository, https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference
|
|
@@ -313,7 +313,7 @@ package is covered, anything underscore-prefixed is internal and may change in
|
|
|
313
313
|
any release. Public names are never removed without a deprecation period.
|
|
314
314
|
|
|
315
315
|
```toml
|
|
316
|
-
dependencies = ["dot-inference>=
|
|
316
|
+
dependencies = ["dot-inference>=2.0,<3"]
|
|
317
317
|
```
|
|
318
318
|
|
|
319
319
|
See [docs/VERSIONING.md](docs/VERSIONING.md) for the full policy.
|
|
@@ -286,7 +286,7 @@ package is covered, anything underscore-prefixed is internal and may change in
|
|
|
286
286
|
any release. Public names are never removed without a deprecation period.
|
|
287
287
|
|
|
288
288
|
```toml
|
|
289
|
-
dependencies = ["dot-inference>=
|
|
289
|
+
dependencies = ["dot-inference>=2.0,<3"]
|
|
290
290
|
```
|
|
291
291
|
|
|
292
292
|
See [docs/VERSIONING.md](docs/VERSIONING.md) for the full policy.
|
|
@@ -164,7 +164,7 @@ sequenceDiagram
|
|
|
164
164
|
end
|
|
165
165
|
end
|
|
166
166
|
|
|
167
|
-
Compat->>Compat: Parse response +
|
|
167
|
+
Compat->>Compat: Parse response + cost (provider-reported, else from ResolvedModelConfig)
|
|
168
168
|
Compat-->>App: LLMResponse (content, cost, tokens)
|
|
169
169
|
```
|
|
170
170
|
|
|
@@ -70,17 +70,35 @@ uv run pytest
|
|
|
70
70
|
uv run pytest --cov=src --cov-report=term-missing
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
###
|
|
73
|
+
### Live API tests
|
|
74
74
|
|
|
75
75
|
The regular suite builds SDK responses from local stubs, so it stays green even
|
|
76
76
|
when the real SDK changes shape — a passing run does not prove an upgrade is
|
|
77
|
-
safe. `
|
|
78
|
-
|
|
77
|
+
safe. Tests marked `live` call the real provider API instead: today that is
|
|
78
|
+
`tests/test_mistral_live.py`, which covers reasoning, structured output, tool
|
|
79
|
+
calling, usage/cost reporting, embeddings and retry behavior on Mistral Small 4
|
|
80
|
+
(`mistral/mistral-small-2603`), with short prompts and capped `max_tokens` — a
|
|
81
|
+
full run costs about $0.0005.
|
|
82
|
+
|
|
83
|
+
They are skipped when no key is present. Run them before and after any
|
|
84
|
+
`mistralai` upgrade:
|
|
79
85
|
|
|
80
86
|
```shell
|
|
81
|
-
MISTRAL_API_KEY=... uv run pytest
|
|
87
|
+
MISTRAL_API_KEY=... uv run pytest -m live -v
|
|
82
88
|
```
|
|
83
89
|
|
|
90
|
+
In CI, the `test` job deselects them (`-m "not live"`) and the
|
|
91
|
+
`test_live_mistral` job runs them when the Mistral client, the shared client
|
|
92
|
+
code, the model registry or the lockfile change; on other pipelines it can be
|
|
93
|
+
started by hand. It needs a `MISTRAL_API_KEY` CI/CD variable, masked but not
|
|
94
|
+
protected, from a dedicated key with a low spending cap. MRs from forks do not
|
|
95
|
+
run it.
|
|
96
|
+
|
|
97
|
+
When adding live tests for another provider, keep the same rules: mark them
|
|
98
|
+
`live`, use the provider's cheapest model that supports the feature under test,
|
|
99
|
+
assert on the shape of the response rather than its wording, and give them
|
|
100
|
+
their own CI job gated on that provider's files.
|
|
101
|
+
|
|
84
102
|
## Examples
|
|
85
103
|
|
|
86
104
|
The examples read credentials from a local env file — see `.env.template`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Versioning and stability
|
|
2
2
|
|
|
3
3
|
`dot-inference` follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
|
-
This page says what that actually covers, so `>=
|
|
4
|
+
This page says what that actually covers, so `>=2.0,<3` means something concrete.
|
|
5
5
|
|
|
6
6
|
## What is public
|
|
7
7
|
|
|
@@ -94,11 +94,11 @@ manual. Use them to validate a release without committing to it.
|
|
|
94
94
|
### From PyPI — the supported way
|
|
95
95
|
|
|
96
96
|
```toml
|
|
97
|
-
dependencies = ["dot-inference>=
|
|
97
|
+
dependencies = ["dot-inference>=2.0,<3"]
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
The upper bound is what makes the guarantees above useful: you get every fix and
|
|
101
|
-
feature of the
|
|
101
|
+
feature of the 2.x line, and never an unannounced break.
|
|
102
102
|
|
|
103
103
|
### From git — development only
|
|
104
104
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dot-inference"
|
|
3
|
-
version = "2.0
|
|
3
|
+
version = "2.1.0"
|
|
4
4
|
description = "Unified async Python library for LLM inference and embeddings across multiple providers"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Kannon For Deep Tech", email = "louis.letarnec@deepika.ai" }]
|
|
@@ -102,6 +102,9 @@ pythonpath = ["src"]
|
|
|
102
102
|
testpaths = ["tests"]
|
|
103
103
|
addopts = "--strict-markers"
|
|
104
104
|
asyncio_default_fixture_loop_scope = "function"
|
|
105
|
+
markers = [
|
|
106
|
+
"live: calls a real provider API (needs credentials, costs money); deselect with -m 'not live'",
|
|
107
|
+
]
|
|
105
108
|
|
|
106
109
|
# For TestPyPI
|
|
107
110
|
[[tool.uv.index]]
|
|
@@ -11,6 +11,7 @@ and provider configuration automatically.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import json
|
|
14
15
|
import logging
|
|
15
16
|
from abc import ABC, abstractmethod
|
|
16
17
|
from collections.abc import AsyncGenerator, Callable
|
|
@@ -39,6 +40,52 @@ from dot_inference.settings import InferenceSettings, Provider, ReasoningEffort
|
|
|
39
40
|
|
|
40
41
|
logger = logging.getLogger(__name__)
|
|
41
42
|
|
|
43
|
+
#: Finish reasons of a model that ended its answer on its own.
|
|
44
|
+
_NORMAL_FINISH = frozenset({"stop", "tool_calls"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dump(obj: Any) -> Any:
|
|
48
|
+
"""JSON-ready view of an SDK object, provider extension fields included
|
|
49
|
+
(OpenRouter's ``provider``, ``native_finish_reason``, ``error``…)."""
|
|
50
|
+
model_dump = getattr(obj, "model_dump", None)
|
|
51
|
+
return model_dump(exclude_none=True) if callable(model_dump) else obj
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _to_json(obj: Any) -> str:
|
|
55
|
+
return json.dumps(obj, ensure_ascii=False, default=str)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _message_shape(message: Any) -> dict[str, Any]:
|
|
59
|
+
"""Role and size of one message, without its content."""
|
|
60
|
+
msg = _dump(message)
|
|
61
|
+
if not isinstance(msg, dict):
|
|
62
|
+
return {"type": type(message).__name__}
|
|
63
|
+
content = msg.get("content")
|
|
64
|
+
shape: dict[str, Any] = {"role": str(msg.get("role")), "chars": len(_to_json(content))}
|
|
65
|
+
if isinstance(content, list):
|
|
66
|
+
shape["parts"] = len(content)
|
|
67
|
+
if "cache_control" in _to_json(content):
|
|
68
|
+
shape["cache_control"] = True
|
|
69
|
+
if msg.get("tool_calls"):
|
|
70
|
+
shape["tool_calls"] = len(msg["tool_calls"])
|
|
71
|
+
if msg.get("tool_call_id"):
|
|
72
|
+
shape["tool_call_id"] = msg["tool_call_id"]
|
|
73
|
+
return shape
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _summarize_request(request: dict[str, Any]) -> dict[str, Any]:
|
|
77
|
+
"""The request minus its content: parameters, tool names, message shapes."""
|
|
78
|
+
messages = request.get("messages") or []
|
|
79
|
+
tools = request.get("tools") or []
|
|
80
|
+
return {
|
|
81
|
+
"chars": len(_to_json(request)),
|
|
82
|
+
"params": {k: v for k, v in request.items() if k not in ("messages", "tools")},
|
|
83
|
+
"tools": [
|
|
84
|
+
(t.get("function") or {}).get("name") if isinstance(t, dict) else str(t) for t in tools
|
|
85
|
+
],
|
|
86
|
+
"messages": [_message_shape(m) for m in messages],
|
|
87
|
+
}
|
|
88
|
+
|
|
42
89
|
|
|
43
90
|
class LLMClient(ABC):
|
|
44
91
|
"""Abstract base class for LLM clients.
|
|
@@ -66,6 +113,40 @@ class LLMClient(ABC):
|
|
|
66
113
|
is a human-readable description of the failure.
|
|
67
114
|
"""
|
|
68
115
|
|
|
116
|
+
def _log_response(
|
|
117
|
+
self,
|
|
118
|
+
logger: logging.Logger,
|
|
119
|
+
finish_reason: str | None,
|
|
120
|
+
body: Any,
|
|
121
|
+
request: dict[str, Any],
|
|
122
|
+
response_id: str | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Log one response; an abnormal one also summarizes ``request``.
|
|
125
|
+
|
|
126
|
+
``response_id`` is the provider's id for the call (e.g. OpenRouter's
|
|
127
|
+
``x-generation-id``), to look it up on the provider's side.
|
|
128
|
+
"""
|
|
129
|
+
if not isinstance(response_id, str):
|
|
130
|
+
response_id = None
|
|
131
|
+
if finish_reason in _NORMAL_FINISH:
|
|
132
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
133
|
+
logger.debug(
|
|
134
|
+
"response from '%s': finish_reason=%s id=%s body=%s",
|
|
135
|
+
self._model,
|
|
136
|
+
finish_reason,
|
|
137
|
+
response_id,
|
|
138
|
+
_to_json(body),
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
logger.warning(
|
|
142
|
+
"response from '%s': finish_reason=%s id=%s body=%s request=%s",
|
|
143
|
+
self._model,
|
|
144
|
+
finish_reason,
|
|
145
|
+
response_id,
|
|
146
|
+
_to_json(body),
|
|
147
|
+
_to_json(_summarize_request(request)),
|
|
148
|
+
)
|
|
149
|
+
|
|
69
150
|
@abstractmethod
|
|
70
151
|
async def call(
|
|
71
152
|
self,
|
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
import httpx
|
|
20
20
|
from pydantic import BaseModel
|
|
21
21
|
|
|
22
|
-
from dot_inference._client import EmbeddingClient, LLMClient
|
|
22
|
+
from dot_inference._client import EmbeddingClient, LLMClient, _dump
|
|
23
23
|
from dot_inference._cost_tracker import compute_cost
|
|
24
24
|
from dot_inference._llm_registry import ResolvedModelConfig
|
|
25
25
|
from dot_inference._retry import compute_wait
|
|
@@ -268,6 +268,9 @@ class MistralClient(LLMClient):
|
|
|
268
268
|
cost or "?",
|
|
269
269
|
)
|
|
270
270
|
|
|
271
|
+
finish_reason = response.choices[0].finish_reason if response.choices else None
|
|
272
|
+
self._log_response(LOGGER, finish_reason, _dump(response), create_kwargs, response.id)
|
|
273
|
+
|
|
271
274
|
msg = response.choices[0].message
|
|
272
275
|
# Reasoning models return content as list[ContentChunk]; keep the
|
|
273
276
|
# answer text only (thinking is display-only and dropped here).
|
|
@@ -294,6 +297,7 @@ class MistralClient(LLMClient):
|
|
|
294
297
|
content=text_content or None,
|
|
295
298
|
tool_calls=tool_calls,
|
|
296
299
|
structured_output=structured_output,
|
|
300
|
+
finish_reason=finish_reason,
|
|
297
301
|
cost=cost,
|
|
298
302
|
prompt_tokens=prompt_tokens,
|
|
299
303
|
completion_tokens=completion_tokens,
|
|
@@ -331,6 +335,7 @@ class MistralClient(LLMClient):
|
|
|
331
335
|
prompt_tokens: int | None = None
|
|
332
336
|
completion_tokens: int | None = None
|
|
333
337
|
last_usage: Any = None
|
|
338
|
+
finish_reason: str | None = None
|
|
334
339
|
|
|
335
340
|
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
|
336
341
|
has_yielded = False
|
|
@@ -341,6 +346,9 @@ class MistralClient(LLMClient):
|
|
|
341
346
|
prompt_tokens = None
|
|
342
347
|
completion_tokens = None
|
|
343
348
|
last_usage = None
|
|
349
|
+
finish_reason = None
|
|
350
|
+
response_id = None
|
|
351
|
+
n_chunks = 0
|
|
344
352
|
response = None
|
|
345
353
|
|
|
346
354
|
try:
|
|
@@ -348,6 +356,8 @@ class MistralClient(LLMClient):
|
|
|
348
356
|
|
|
349
357
|
async for event in response:
|
|
350
358
|
data = event.data
|
|
359
|
+
n_chunks += 1
|
|
360
|
+
response_id = response_id or getattr(data, "id", None)
|
|
351
361
|
|
|
352
362
|
if data.usage:
|
|
353
363
|
prompt_tokens = data.usage.prompt_tokens
|
|
@@ -413,6 +423,9 @@ class MistralClient(LLMClient):
|
|
|
413
423
|
yield ToolCallDone(tool_call=tc)
|
|
414
424
|
tool_acc.clear()
|
|
415
425
|
|
|
426
|
+
if choice.finish_reason:
|
|
427
|
+
finish_reason = choice.finish_reason
|
|
428
|
+
|
|
416
429
|
break # Stream completed successfully
|
|
417
430
|
|
|
418
431
|
except (MistralError, httpx.TransportError) as exc:
|
|
@@ -466,6 +479,18 @@ class MistralClient(LLMClient):
|
|
|
466
479
|
)
|
|
467
480
|
|
|
468
481
|
content = "".join(content_parts) or None
|
|
482
|
+
self._log_response(
|
|
483
|
+
LOGGER,
|
|
484
|
+
finish_reason,
|
|
485
|
+
{
|
|
486
|
+
"content": content,
|
|
487
|
+
"tool_calls": [tc.model_dump() for tc in finished_tool_calls],
|
|
488
|
+
"chunks": n_chunks,
|
|
489
|
+
"usage": _dump(last_usage),
|
|
490
|
+
},
|
|
491
|
+
create_kwargs,
|
|
492
|
+
response_id,
|
|
493
|
+
)
|
|
469
494
|
structured_output = None
|
|
470
495
|
if response_model is not None:
|
|
471
496
|
if not content:
|
|
@@ -476,6 +501,7 @@ class MistralClient(LLMClient):
|
|
|
476
501
|
content=content,
|
|
477
502
|
tool_calls=finished_tool_calls or None,
|
|
478
503
|
structured_output=structured_output,
|
|
504
|
+
finish_reason=finish_reason,
|
|
479
505
|
cost=cost,
|
|
480
506
|
prompt_tokens=prompt_tokens,
|
|
481
507
|
completion_tokens=completion_tokens,
|
|
@@ -19,7 +19,7 @@ import openai
|
|
|
19
19
|
from openai import AsyncOpenAI
|
|
20
20
|
from pydantic import BaseModel
|
|
21
21
|
|
|
22
|
-
from dot_inference._client import EmbeddingClient, LLMClient
|
|
22
|
+
from dot_inference._client import EmbeddingClient, LLMClient, _dump
|
|
23
23
|
from dot_inference._cost_tracker import compute_cost
|
|
24
24
|
from dot_inference._llm_registry import ResolvedModelConfig
|
|
25
25
|
from dot_inference._retry import compute_wait
|
|
@@ -99,6 +99,27 @@ def _extract_cached_tokens(usage: Any) -> int | None:
|
|
|
99
99
|
return getattr(details, "cached_tokens", None)
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
def _reported_cost(usage: Any) -> float | None:
|
|
103
|
+
"""The request's cost as the provider billed it, if it reports one.
|
|
104
|
+
|
|
105
|
+
OpenRouter returns it as ``usage.cost`` (USD, cached tokens included) on
|
|
106
|
+
every response, streamed or not. Returns None when the provider does not
|
|
107
|
+
report it.
|
|
108
|
+
"""
|
|
109
|
+
cost = getattr(usage, "cost", None) if usage is not None else None
|
|
110
|
+
if isinstance(cost, bool) or not isinstance(cost, (int, float)):
|
|
111
|
+
return None
|
|
112
|
+
return float(cost)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _generation_id(stream: Any) -> str | None:
|
|
116
|
+
"""OpenRouter's id for the call (``x-generation-id`` header) — there even
|
|
117
|
+
when the stream carries no chunk at all."""
|
|
118
|
+
headers = getattr(getattr(stream, "response", None), "headers", None)
|
|
119
|
+
value = headers.get("x-generation-id") if headers is not None else None
|
|
120
|
+
return value if isinstance(value, str) else None
|
|
121
|
+
|
|
122
|
+
|
|
102
123
|
class OpenAICompatClient(LLMClient):
|
|
103
124
|
"""LLM client for any OpenAI-compatible endpoint.
|
|
104
125
|
|
|
@@ -218,6 +239,18 @@ class OpenAICompatClient(LLMClient):
|
|
|
218
239
|
|
|
219
240
|
return create_kwargs, response_model
|
|
220
241
|
|
|
242
|
+
def _cost(self, usage: Any) -> float | None:
|
|
243
|
+
"""What the call cost: as the provider billed it when it says, else
|
|
244
|
+
estimated from the registry's per-token prices (None if unknown)."""
|
|
245
|
+
reported = _reported_cost(usage)
|
|
246
|
+
if reported is not None or self._model_config is None:
|
|
247
|
+
return reported
|
|
248
|
+
return compute_cost(
|
|
249
|
+
usage,
|
|
250
|
+
self._model_config.input_token_cost,
|
|
251
|
+
self._model_config.output_token_cost,
|
|
252
|
+
)
|
|
253
|
+
|
|
221
254
|
async def _create_with_retry(self, create_kwargs: dict[str, Any]) -> Any:
|
|
222
255
|
"""Call ``chat.completions.create`` with transient-error retry."""
|
|
223
256
|
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
|
@@ -267,13 +300,7 @@ class OpenAICompatClient(LLMClient):
|
|
|
267
300
|
completion_tokens = response.usage.completion_tokens if response.usage else None
|
|
268
301
|
cached_tokens = _extract_cached_tokens(response.usage)
|
|
269
302
|
|
|
270
|
-
cost =
|
|
271
|
-
if self._model_config:
|
|
272
|
-
cost = compute_cost(
|
|
273
|
-
response.usage,
|
|
274
|
-
self._model_config.input_token_cost,
|
|
275
|
-
self._model_config.output_token_cost,
|
|
276
|
-
)
|
|
303
|
+
cost = self._cost(response.usage)
|
|
277
304
|
if response.usage:
|
|
278
305
|
LOGGER.debug(
|
|
279
306
|
"tokens: %s in (%s cached) / %s out, cost=$%s",
|
|
@@ -283,6 +310,9 @@ class OpenAICompatClient(LLMClient):
|
|
|
283
310
|
cost or "?",
|
|
284
311
|
)
|
|
285
312
|
|
|
313
|
+
finish_reason = response.choices[0].finish_reason if response.choices else None
|
|
314
|
+
self._log_response(LOGGER, finish_reason, _dump(response), create_kwargs, response.id)
|
|
315
|
+
|
|
286
316
|
msg = response.choices[0].message
|
|
287
317
|
|
|
288
318
|
# Parse native tool calls if present
|
|
@@ -301,7 +331,6 @@ class OpenAICompatClient(LLMClient):
|
|
|
301
331
|
structured_output = None
|
|
302
332
|
if response_model is not None:
|
|
303
333
|
if not msg.content:
|
|
304
|
-
finish_reason = response.choices[0].finish_reason
|
|
305
334
|
refusal = getattr(msg, "refusal", None)
|
|
306
335
|
raise RuntimeError(
|
|
307
336
|
f"LLM returned empty content with response_format set "
|
|
@@ -315,6 +344,7 @@ class OpenAICompatClient(LLMClient):
|
|
|
315
344
|
content=msg.content,
|
|
316
345
|
tool_calls=tool_calls,
|
|
317
346
|
structured_output=structured_output,
|
|
347
|
+
finish_reason=finish_reason,
|
|
318
348
|
cost=cost,
|
|
319
349
|
prompt_tokens=prompt_tokens,
|
|
320
350
|
completion_tokens=completion_tokens,
|
|
@@ -355,6 +385,8 @@ class OpenAICompatClient(LLMClient):
|
|
|
355
385
|
completion_tokens: int | None = None
|
|
356
386
|
cached_tokens: int | None = None
|
|
357
387
|
last_usage: Any = None
|
|
388
|
+
finish_reason: str | None = None
|
|
389
|
+
native_finish_reason: str | None = None
|
|
358
390
|
|
|
359
391
|
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
|
360
392
|
has_yielded = False
|
|
@@ -366,12 +398,19 @@ class OpenAICompatClient(LLMClient):
|
|
|
366
398
|
completion_tokens = None
|
|
367
399
|
cached_tokens = None
|
|
368
400
|
last_usage = None
|
|
401
|
+
finish_reason = None
|
|
402
|
+
native_finish_reason = None
|
|
403
|
+
response_id = None
|
|
404
|
+
n_chunks = 0
|
|
369
405
|
response = None
|
|
370
406
|
|
|
371
407
|
try:
|
|
372
408
|
response = await self._client.chat.completions.create(**create_kwargs)
|
|
409
|
+
response_id = _generation_id(response)
|
|
373
410
|
|
|
374
411
|
async for chunk in response:
|
|
412
|
+
n_chunks += 1
|
|
413
|
+
response_id = response_id or getattr(chunk, "id", None)
|
|
375
414
|
if chunk.usage:
|
|
376
415
|
prompt_tokens = chunk.usage.prompt_tokens
|
|
377
416
|
completion_tokens = chunk.usage.completion_tokens
|
|
@@ -445,6 +484,11 @@ class OpenAICompatClient(LLMClient):
|
|
|
445
484
|
yield ToolCallDone(tool_call=tc)
|
|
446
485
|
tool_acc.clear()
|
|
447
486
|
|
|
487
|
+
if choice.finish_reason:
|
|
488
|
+
finish_reason = choice.finish_reason
|
|
489
|
+
# OpenRouter: the provider's own reason, before normalization
|
|
490
|
+
native_finish_reason = getattr(choice, "native_finish_reason", None)
|
|
491
|
+
|
|
448
492
|
break # Stream completed successfully
|
|
449
493
|
|
|
450
494
|
except openai.APIError as exc:
|
|
@@ -473,14 +517,7 @@ class OpenAICompatClient(LLMClient):
|
|
|
473
517
|
if inspect.isawaitable(result):
|
|
474
518
|
await result
|
|
475
519
|
|
|
476
|
-
|
|
477
|
-
cost = None
|
|
478
|
-
if self._model_config:
|
|
479
|
-
cost = compute_cost(
|
|
480
|
-
last_usage,
|
|
481
|
-
self._model_config.input_token_cost,
|
|
482
|
-
self._model_config.output_token_cost,
|
|
483
|
-
)
|
|
520
|
+
cost = self._cost(last_usage)
|
|
484
521
|
if prompt_tokens is not None:
|
|
485
522
|
LOGGER.debug(
|
|
486
523
|
"tokens: %s in (%s cached) / %s out, cost=$%s",
|
|
@@ -500,6 +537,19 @@ class OpenAICompatClient(LLMClient):
|
|
|
500
537
|
|
|
501
538
|
# Final assembled response
|
|
502
539
|
content = "".join(content_parts) or None
|
|
540
|
+
self._log_response(
|
|
541
|
+
LOGGER,
|
|
542
|
+
finish_reason,
|
|
543
|
+
{
|
|
544
|
+
"content": content,
|
|
545
|
+
"tool_calls": [tc.model_dump() for tc in finished_tool_calls],
|
|
546
|
+
"chunks": n_chunks,
|
|
547
|
+
"usage": _dump(last_usage),
|
|
548
|
+
"native_finish_reason": native_finish_reason,
|
|
549
|
+
},
|
|
550
|
+
create_kwargs,
|
|
551
|
+
response_id,
|
|
552
|
+
)
|
|
503
553
|
structured_output = None
|
|
504
554
|
if response_model is not None:
|
|
505
555
|
if not content:
|
|
@@ -510,6 +560,7 @@ class OpenAICompatClient(LLMClient):
|
|
|
510
560
|
content=content,
|
|
511
561
|
tool_calls=finished_tool_calls or None,
|
|
512
562
|
structured_output=structured_output,
|
|
563
|
+
finish_reason=finish_reason,
|
|
513
564
|
cost=cost,
|
|
514
565
|
prompt_tokens=prompt_tokens,
|
|
515
566
|
completion_tokens=completion_tokens,
|
|
@@ -80,6 +80,10 @@ class LLMResponse(BaseModel):
|
|
|
80
80
|
content: str | None = None
|
|
81
81
|
tool_calls: list[ToolCall] | None = None
|
|
82
82
|
structured_output: BaseModel | None = None
|
|
83
|
+
#: Why the model stopped, as the provider reports it (``stop``,
|
|
84
|
+
#: ``tool_calls``, ``length``, ``content_filter``, ``error``…); None when
|
|
85
|
+
#: the provider gave none.
|
|
86
|
+
finish_reason: str | None = None
|
|
83
87
|
cost: float | None = None
|
|
84
88
|
prompt_tokens: int | None = None
|
|
85
89
|
completion_tokens: int | None = None
|
|
@@ -309,14 +309,14 @@ mistral/mistral-large-latest:
|
|
|
309
309
|
# Alias — always resolves to the latest Mistral Small release.
|
|
310
310
|
# Use this when you want automatic upgrades as Mistral ships new versions.
|
|
311
311
|
# Note: behavior may change silently when the alias is reassigned.
|
|
312
|
-
#
|
|
313
|
-
# Reasoning: the Mistral REST API supports reasoning_effort ("high"/"none")
|
|
314
|
-
# for this model, but the Python SDK (mistralai v2.0.2) does not yet expose
|
|
315
|
-
# the parameter. Reasoning is disabled at the model level to keep behavior
|
|
316
|
-
# consistent across providers. Re-enable once the SDK adds support.
|
|
317
312
|
mistral/mistral-small-latest:
|
|
318
313
|
default_provider: mistral
|
|
319
314
|
supports_strict_json: true
|
|
315
|
+
# Adjustable reasoning, same contract as Medium 3.5: "none" (no thinking
|
|
316
|
+
# chunk — also the API default) or "high" (full thinking chunk).
|
|
317
|
+
supports_reasoning: true
|
|
318
|
+
reasoning_effort_options: [none, high]
|
|
319
|
+
default_reasoning_effort: none
|
|
320
320
|
input_token_cost: 1.5e-7 # $0.15/M
|
|
321
321
|
output_token_cost: 6e-7 # $0.60/M
|
|
322
322
|
context_window: 256000
|
|
@@ -335,10 +335,14 @@ mistral/mistral-small-latest:
|
|
|
335
335
|
# Kept alongside the alias for production stability: pipelines that require
|
|
336
336
|
# reproducible behavior should reference this ID so they are unaffected when
|
|
337
337
|
# mistral-small-latest is reassigned to a future release.
|
|
338
|
-
#
|
|
338
|
+
# Also the model the live CI tests run against (tests/test_mistral_live.py).
|
|
339
339
|
mistral/mistral-small-2603:
|
|
340
340
|
default_provider: mistral
|
|
341
341
|
supports_strict_json: true
|
|
342
|
+
# Reasoning: see the alias above.
|
|
343
|
+
supports_reasoning: true
|
|
344
|
+
reasoning_effort_options: [none, high]
|
|
345
|
+
default_reasoning_effort: none
|
|
342
346
|
input_token_cost: 1.5e-7 # $0.15/M
|
|
343
347
|
output_token_cost: 6e-7 # $0.60/M
|
|
344
348
|
providers:
|