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.
Files changed (50) hide show
  1. {dot_inference-2.0.2 → dot_inference-2.1.0}/.gitlab-ci.yml +49 -2
  2. {dot_inference-2.0.2 → dot_inference-2.1.0}/CHANGELOG.md +39 -1
  3. {dot_inference-2.0.2 → dot_inference-2.1.0}/PKG-INFO +2 -2
  4. {dot_inference-2.0.2 → dot_inference-2.1.0}/README.md +1 -1
  5. {dot_inference-2.0.2 → dot_inference-2.1.0}/docs/ARCHITECTURE.md +1 -1
  6. {dot_inference-2.0.2 → dot_inference-2.1.0}/docs/DEVELOPMENT.md +22 -4
  7. {dot_inference-2.0.2 → dot_inference-2.1.0}/docs/VERSIONING.md +3 -3
  8. {dot_inference-2.0.2 → dot_inference-2.1.0}/pyproject.toml +4 -1
  9. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_client.py +81 -0
  10. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_mistral_compat.py +27 -1
  11. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_openai_compat.py +68 -17
  12. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_types.py +4 -0
  13. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/models.yaml +10 -6
  14. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_mistral_compat.py +19 -0
  15. dot_inference-2.1.0/tests/test_mistral_live.py +249 -0
  16. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_openai_compat.py +296 -7
  17. {dot_inference-2.0.2 → dot_inference-2.1.0}/uv.lock +1 -1
  18. dot_inference-2.0.2/tests/test_mistral_live.py +0 -142
  19. {dot_inference-2.0.2 → dot_inference-2.1.0}/.env.template +0 -0
  20. {dot_inference-2.0.2 → dot_inference-2.1.0}/.gitignore +0 -0
  21. {dot_inference-2.0.2 → dot_inference-2.1.0}/.pre-commit-config.yaml +0 -0
  22. {dot_inference-2.0.2 → dot_inference-2.1.0}/.python-version +0 -0
  23. {dot_inference-2.0.2 → dot_inference-2.1.0}/AUTHORS.md +0 -0
  24. {dot_inference-2.0.2 → dot_inference-2.1.0}/CONTRIBUTING.md +0 -0
  25. {dot_inference-2.0.2 → dot_inference-2.1.0}/DCO +0 -0
  26. {dot_inference-2.0.2 → dot_inference-2.1.0}/LICENSE.md +0 -0
  27. {dot_inference-2.0.2 → dot_inference-2.1.0}/docs/PUBLISHING.md +0 -0
  28. {dot_inference-2.0.2 → dot_inference-2.1.0}/examples/demo.py +0 -0
  29. {dot_inference-2.0.2 → dot_inference-2.1.0}/examples/demo_streaming.py +0 -0
  30. {dot_inference-2.0.2 → dot_inference-2.1.0}/examples/doc_stream.md +0 -0
  31. {dot_inference-2.0.2 → dot_inference-2.1.0}/scripts/test_all_models.py +0 -0
  32. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/README.md +0 -0
  33. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/__init__.py +0 -0
  34. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_cost_tracker.py +0 -0
  35. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_llm_registry.py +0 -0
  36. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_local_check.py +0 -0
  37. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_rate_limit.py +0 -0
  38. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_retry.py +0 -0
  39. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/_sync.py +0 -0
  40. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/mock.py +0 -0
  41. {dot_inference-2.0.2 → dot_inference-2.1.0}/src/dot_inference/settings.py +0 -0
  42. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/__init__.py +0 -0
  43. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_cost_tracker.py +0 -0
  44. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_factories.py +0 -0
  45. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_llm_registry.py +0 -0
  46. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_local_check.py +0 -0
  47. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_rate_limit.py +0 -0
  48. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_retry.py +0 -0
  49. {dot_inference-2.0.2 → dot_inference-2.1.0}/tests/test_settings.py +0 -0
  50. {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
- - uv run --python "$PYTHON_VERSION" pytest --cov=src --cov-report=term --cov-report=xml
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.2...main
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.2
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>=1.0,<2"]
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>=1.0,<2"]
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 + compute cost from ResolvedModelConfig
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
- ### Before upgrading the Mistral SDK
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. `tests/test_mistral_live.py` checks the four silent-failure spots against
78
- the real API; it is skipped when no key is present.
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 tests/test_mistral_live.py -v
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 `>=1.0,<2` means something concrete.
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>=1.0,<2"]
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 1.x line, and never an unannounced break.
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.2"
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 = None
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
- # Cost
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
- # Same SDK limitation as the alias above — see comment there.
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: