dot-inference 2.0.3__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.3 → dot_inference-2.1.0}/.gitlab-ci.yml +49 -2
- {dot_inference-2.0.3 → dot_inference-2.1.0}/CHANGELOG.md +28 -1
- {dot_inference-2.0.3 → dot_inference-2.1.0}/PKG-INFO +1 -1
- {dot_inference-2.0.3 → dot_inference-2.1.0}/docs/DEVELOPMENT.md +22 -4
- {dot_inference-2.0.3 → dot_inference-2.1.0}/pyproject.toml +4 -1
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_client.py +81 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_mistral_compat.py +27 -1
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_openai_compat.py +41 -2
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_types.py +4 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/models.yaml +10 -6
- {dot_inference-2.0.3 → 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.3 → dot_inference-2.1.0}/tests/test_openai_compat.py +213 -8
- {dot_inference-2.0.3 → dot_inference-2.1.0}/uv.lock +1 -1
- dot_inference-2.0.3/tests/test_mistral_live.py +0 -142
- {dot_inference-2.0.3 → dot_inference-2.1.0}/.env.template +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/.gitignore +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/.pre-commit-config.yaml +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/.python-version +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/AUTHORS.md +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/CONTRIBUTING.md +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/DCO +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/LICENSE.md +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/README.md +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/docs/ARCHITECTURE.md +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/docs/PUBLISHING.md +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/docs/VERSIONING.md +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/examples/demo.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/examples/demo_streaming.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/examples/doc_stream.md +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/scripts/test_all_models.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/README.md +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/__init__.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_cost_tracker.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_llm_registry.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_local_check.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_rate_limit.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_retry.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/_sync.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/mock.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/src/dot_inference/settings.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/tests/__init__.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/tests/test_cost_tracker.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/tests/test_factories.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/tests/test_llm_registry.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/tests/test_local_check.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/tests/test_rate_limit.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/tests/test_retry.py +0 -0
- {dot_inference-2.0.3 → dot_inference-2.1.0}/tests/test_settings.py +0 -0
- {dot_inference-2.0.3 → 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,32 @@ 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
|
+
|
|
10
36
|
## [2.0.3] - 2026-09-30
|
|
11
37
|
|
|
12
38
|
### Fixed
|
|
@@ -72,7 +98,8 @@ this release.
|
|
|
72
98
|
- `LLMCallable` and `EmbeddingCallable` return a `Coroutine` rather than the
|
|
73
99
|
looser `Awaitable`, matching what `asyncio.run_coroutine_threadsafe` accepts.
|
|
74
100
|
|
|
75
|
-
[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
|
|
76
103
|
[2.0.3]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.0.3
|
|
77
104
|
[2.0.2]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.0.2
|
|
78
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
|
|
@@ -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,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
|
|
@@ -112,6 +112,14 @@ def _reported_cost(usage: Any) -> float | None:
|
|
|
112
112
|
return float(cost)
|
|
113
113
|
|
|
114
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
|
+
|
|
115
123
|
class OpenAICompatClient(LLMClient):
|
|
116
124
|
"""LLM client for any OpenAI-compatible endpoint.
|
|
117
125
|
|
|
@@ -302,6 +310,9 @@ class OpenAICompatClient(LLMClient):
|
|
|
302
310
|
cost or "?",
|
|
303
311
|
)
|
|
304
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
|
+
|
|
305
316
|
msg = response.choices[0].message
|
|
306
317
|
|
|
307
318
|
# Parse native tool calls if present
|
|
@@ -320,7 +331,6 @@ class OpenAICompatClient(LLMClient):
|
|
|
320
331
|
structured_output = None
|
|
321
332
|
if response_model is not None:
|
|
322
333
|
if not msg.content:
|
|
323
|
-
finish_reason = response.choices[0].finish_reason
|
|
324
334
|
refusal = getattr(msg, "refusal", None)
|
|
325
335
|
raise RuntimeError(
|
|
326
336
|
f"LLM returned empty content with response_format set "
|
|
@@ -334,6 +344,7 @@ class OpenAICompatClient(LLMClient):
|
|
|
334
344
|
content=msg.content,
|
|
335
345
|
tool_calls=tool_calls,
|
|
336
346
|
structured_output=structured_output,
|
|
347
|
+
finish_reason=finish_reason,
|
|
337
348
|
cost=cost,
|
|
338
349
|
prompt_tokens=prompt_tokens,
|
|
339
350
|
completion_tokens=completion_tokens,
|
|
@@ -374,6 +385,8 @@ class OpenAICompatClient(LLMClient):
|
|
|
374
385
|
completion_tokens: int | None = None
|
|
375
386
|
cached_tokens: int | None = None
|
|
376
387
|
last_usage: Any = None
|
|
388
|
+
finish_reason: str | None = None
|
|
389
|
+
native_finish_reason: str | None = None
|
|
377
390
|
|
|
378
391
|
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
|
379
392
|
has_yielded = False
|
|
@@ -385,12 +398,19 @@ class OpenAICompatClient(LLMClient):
|
|
|
385
398
|
completion_tokens = None
|
|
386
399
|
cached_tokens = None
|
|
387
400
|
last_usage = None
|
|
401
|
+
finish_reason = None
|
|
402
|
+
native_finish_reason = None
|
|
403
|
+
response_id = None
|
|
404
|
+
n_chunks = 0
|
|
388
405
|
response = None
|
|
389
406
|
|
|
390
407
|
try:
|
|
391
408
|
response = await self._client.chat.completions.create(**create_kwargs)
|
|
409
|
+
response_id = _generation_id(response)
|
|
392
410
|
|
|
393
411
|
async for chunk in response:
|
|
412
|
+
n_chunks += 1
|
|
413
|
+
response_id = response_id or getattr(chunk, "id", None)
|
|
394
414
|
if chunk.usage:
|
|
395
415
|
prompt_tokens = chunk.usage.prompt_tokens
|
|
396
416
|
completion_tokens = chunk.usage.completion_tokens
|
|
@@ -464,6 +484,11 @@ class OpenAICompatClient(LLMClient):
|
|
|
464
484
|
yield ToolCallDone(tool_call=tc)
|
|
465
485
|
tool_acc.clear()
|
|
466
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
|
+
|
|
467
492
|
break # Stream completed successfully
|
|
468
493
|
|
|
469
494
|
except openai.APIError as exc:
|
|
@@ -512,6 +537,19 @@ class OpenAICompatClient(LLMClient):
|
|
|
512
537
|
|
|
513
538
|
# Final assembled response
|
|
514
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
|
+
)
|
|
515
553
|
structured_output = None
|
|
516
554
|
if response_model is not None:
|
|
517
555
|
if not content:
|
|
@@ -522,6 +560,7 @@ class OpenAICompatClient(LLMClient):
|
|
|
522
560
|
content=content,
|
|
523
561
|
tool_calls=finished_tool_calls or None,
|
|
524
562
|
structured_output=structured_output,
|
|
563
|
+
finish_reason=finish_reason,
|
|
525
564
|
cost=cost,
|
|
526
565
|
prompt_tokens=prompt_tokens,
|
|
527
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:
|
|
@@ -339,3 +339,22 @@ async def test_call_collapses_structured_content_to_text():
|
|
|
339
339
|
result = await client.call([Message(role=Role.USER, content="Hi")])
|
|
340
340
|
|
|
341
341
|
assert result.content == "answer"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# --- Response log: finish_reason + body ---
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pytest.mark.asyncio
|
|
348
|
+
async def test_stream_logs_finish_reason(caplog):
|
|
349
|
+
client = _reasoning_client(supports_reasoning=False)
|
|
350
|
+
events = [_stream_event(content="answer"), _stream_event(finish_reason="error")]
|
|
351
|
+
client._client.chat.stream_async = AsyncMock(return_value=_async_iter(events))
|
|
352
|
+
|
|
353
|
+
with caplog.at_level("DEBUG", logger="dot_inference._mistral_compat"):
|
|
354
|
+
out = [ev async for ev in client.stream([Message(role=Role.USER, content="Hi")])]
|
|
355
|
+
|
|
356
|
+
assert out[-1].response.finish_reason == "error"
|
|
357
|
+
[log] = [r for r in caplog.records if r.getMessage().startswith("response from")]
|
|
358
|
+
assert log.levelname == "WARNING"
|
|
359
|
+
assert "finish_reason=error" in log.getMessage()
|
|
360
|
+
assert '"content": "answer"' in log.getMessage()
|