dot-inference 2.0.3__tar.gz → 2.2.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 (52) hide show
  1. {dot_inference-2.0.3 → dot_inference-2.2.0}/.gitlab-ci.yml +49 -2
  2. {dot_inference-2.0.3 → dot_inference-2.2.0}/CHANGELOG.md +38 -1
  3. {dot_inference-2.0.3 → dot_inference-2.2.0}/PKG-INFO +2 -1
  4. {dot_inference-2.0.3 → dot_inference-2.2.0}/README.md +1 -0
  5. {dot_inference-2.0.3 → dot_inference-2.2.0}/docs/DEVELOPMENT.md +22 -4
  6. dot_inference-2.2.0/docs/provider-quirks/openrouter-errors.md +19 -0
  7. dot_inference-2.2.0/docs/provider-quirks/tool-choice.md +31 -0
  8. {dot_inference-2.0.3 → dot_inference-2.2.0}/pyproject.toml +4 -1
  9. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_client.py +81 -0
  10. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_mistral_compat.py +27 -1
  11. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_openai_compat.py +41 -2
  12. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_types.py +4 -0
  13. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/models.yaml +123 -6
  14. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/test_mistral_compat.py +19 -0
  15. dot_inference-2.2.0/tests/test_mistral_live.py +249 -0
  16. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/test_openai_compat.py +213 -8
  17. {dot_inference-2.0.3 → dot_inference-2.2.0}/uv.lock +1 -1
  18. dot_inference-2.0.3/tests/test_mistral_live.py +0 -142
  19. {dot_inference-2.0.3 → dot_inference-2.2.0}/.env.template +0 -0
  20. {dot_inference-2.0.3 → dot_inference-2.2.0}/.gitignore +0 -0
  21. {dot_inference-2.0.3 → dot_inference-2.2.0}/.pre-commit-config.yaml +0 -0
  22. {dot_inference-2.0.3 → dot_inference-2.2.0}/.python-version +0 -0
  23. {dot_inference-2.0.3 → dot_inference-2.2.0}/AUTHORS.md +0 -0
  24. {dot_inference-2.0.3 → dot_inference-2.2.0}/CONTRIBUTING.md +0 -0
  25. {dot_inference-2.0.3 → dot_inference-2.2.0}/DCO +0 -0
  26. {dot_inference-2.0.3 → dot_inference-2.2.0}/LICENSE.md +0 -0
  27. {dot_inference-2.0.3 → dot_inference-2.2.0}/docs/ARCHITECTURE.md +0 -0
  28. {dot_inference-2.0.3 → dot_inference-2.2.0}/docs/PUBLISHING.md +0 -0
  29. {dot_inference-2.0.3 → dot_inference-2.2.0}/docs/VERSIONING.md +0 -0
  30. {dot_inference-2.0.3 → dot_inference-2.2.0}/examples/demo.py +0 -0
  31. {dot_inference-2.0.3 → dot_inference-2.2.0}/examples/demo_streaming.py +0 -0
  32. {dot_inference-2.0.3 → dot_inference-2.2.0}/examples/doc_stream.md +0 -0
  33. {dot_inference-2.0.3 → dot_inference-2.2.0}/scripts/test_all_models.py +0 -0
  34. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/README.md +0 -0
  35. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/__init__.py +0 -0
  36. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_cost_tracker.py +0 -0
  37. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_llm_registry.py +0 -0
  38. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_local_check.py +0 -0
  39. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_rate_limit.py +0 -0
  40. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_retry.py +0 -0
  41. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/_sync.py +0 -0
  42. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/mock.py +0 -0
  43. {dot_inference-2.0.3 → dot_inference-2.2.0}/src/dot_inference/settings.py +0 -0
  44. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/__init__.py +0 -0
  45. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/test_cost_tracker.py +0 -0
  46. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/test_factories.py +0 -0
  47. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/test_llm_registry.py +0 -0
  48. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/test_local_check.py +0 -0
  49. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/test_rate_limit.py +0 -0
  50. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/test_retry.py +0 -0
  51. {dot_inference-2.0.3 → dot_inference-2.2.0}/tests/test_settings.py +0 -0
  52. {dot_inference-2.0.3 → dot_inference-2.2.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,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.0] - 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - Models: `anthropic/claude-haiku-5.5`, `anthropic/claude-sonnet-5.5`,
15
+ `anthropic/claude-opus-5.5`, `openai/gpt-6-luna`, `openai/gpt-6.1-sol`,
16
+ `google/gemini-3.8-flash` and `mistral/mistral-large-4` (public preview;
17
+ `reasoning_effort` `none` or `high`, the default).
18
+
19
+ ## [2.1.0] - 2026-10-07
20
+
21
+ ### Added
22
+
23
+ - `LLMResponse.finish_reason`: why the model stopped, as the provider reports
24
+ it (`stop`, `tool_calls`, `length`, `content_filter`, `error`…), for both
25
+ `call()` and `stream()`, OpenAI-compatible and Mistral clients.
26
+ - Every LLM response is logged once with its `finish_reason` and body —
27
+ `response from '<model>': finish_reason=… id=… body=…`. `call()` logs the
28
+ raw response; `stream()` the assembled content and tool calls, the chunk
29
+ count and the usage — plus, on OpenRouter, `native_finish_reason`. DEBUG when the model ended on its own (`stop`,
30
+ `tool_calls`), WARNING otherwise — including a stream that ends without any
31
+ finish reason, which previously left no trace.
32
+ - An abnormal response's line also summarizes the request that produced it,
33
+ without its content: parameters (`extra_body` included), tool names, each
34
+ 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
35
+ `x-generation-id` header for streams (present even when no chunk arrives),
36
+ the response id otherwise.
37
+
38
+ ### Changed
39
+
40
+ - `mistral/mistral-small-latest` and `mistral/mistral-small-2603` (Mistral
41
+ Small 4) are now declared with `supports_reasoning: true`, accepting
42
+ `reasoning_effort` `none` (the default, matching the API's) or `high`. As
43
+ for every reasoning model, `temperature` is no longer forwarded for them.
44
+
10
45
  ## [2.0.3] - 2026-09-30
11
46
 
12
47
  ### Fixed
@@ -72,7 +107,9 @@ this release.
72
107
  - `LLMCallable` and `EmbeddingCallable` return a `Coroutine` rather than the
73
108
  looser `Awaitable`, matching what `asyncio.run_coroutine_threadsafe` accepts.
74
109
 
75
- [Unreleased]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/compare/v2.0.3...main
110
+ [Unreleased]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/compare/v2.2.0...main
111
+ [2.2.0]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.2.0
112
+ [2.1.0]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.1.0
76
113
  [2.0.3]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.0.3
77
114
  [2.0.2]: https://gitlab.com/deepika6190303/deepika-open-toolbox/dot-inference/-/tags/v2.0.2
78
115
  [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
3
+ Version: 2.2.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
@@ -332,6 +332,7 @@ See [docs/VERSIONING.md](docs/VERSIONING.md) for the full policy.
332
332
  | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Environment setup, tests, code style |
333
333
  | [docs/VERSIONING.md](docs/VERSIONING.md) | Versioning, deprecation policy, how to depend on this package |
334
334
  | [docs/PUBLISHING.md](docs/PUBLISHING.md) | Cutting a release |
335
+ | [docs/provider-quirks/](docs/provider-quirks/) | Provider behaviours that differ from the OpenAI format: `tool_choice` support, OpenRouter error bodies |
335
336
  | [CHANGELOG.md](CHANGELOG.md) | Release history |
336
337
 
337
338
  ## Contributing
@@ -305,6 +305,7 @@ See [docs/VERSIONING.md](docs/VERSIONING.md) for the full policy.
305
305
  | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Environment setup, tests, code style |
306
306
  | [docs/VERSIONING.md](docs/VERSIONING.md) | Versioning, deprecation policy, how to depend on this package |
307
307
  | [docs/PUBLISHING.md](docs/PUBLISHING.md) | Cutting a release |
308
+ | [docs/provider-quirks/](docs/provider-quirks/) | Provider behaviours that differ from the OpenAI format: `tool_choice` support, OpenRouter error bodies |
308
309
  | [CHANGELOG.md](CHANGELOG.md) | Release history |
309
310
 
310
311
  ## Contributing
@@ -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`
@@ -0,0 +1,19 @@
1
+ # OpenRouter error bodies
2
+
3
+ OpenRouter translates requests into each provider's format, but passes the
4
+ provider's error back untranslated, wrapped in a generic envelope
5
+ ([Errors and debugging](https://openrouter.ai/docs/api/reference/errors-and-debugging#provider-errors)):
6
+
7
+ ```json
8
+ {"error": {"code": 400, "message": "Provider returned error",
9
+ "metadata": {"raw": "<the provider's error body, verbatim>",
10
+ "provider_name": "Anthropic"}}}
11
+ ```
12
+
13
+ - `message` is generic; the actual cause is in `metadata.raw`.
14
+ - `metadata.raw` uses the provider's vocabulary, not the OpenAI one used in
15
+ the request. Example: a `tool_choice: "required"` refused by Anthropic is
16
+ reported as `type "tool" and "any" are not supported` (see
17
+ [tool-choice.md](tool-choice.md)).
18
+ - `metadata.provider_name` names the provider that failed — useful when
19
+ routing is not pinned with `provider_routing`.
@@ -0,0 +1,31 @@
1
+ # `tool_choice` across providers
2
+
3
+ dot-inference sends `tool_choice` in OpenAI format. Its meaning
4
+ ([OpenAI](https://platform.openai.com/docs/guides/function-calling),
5
+ [OpenRouter](https://openrouter.ai/docs/guides/features/tool-calling#tool-choice-configuration)):
6
+
7
+ | Value | Meaning |
8
+ |---|---|
9
+ | `"auto"` | The model calls tools or answers in text, as it decides. Default when `tools` is given. |
10
+ | `"none"` | Tools stay in the request but the model must answer in text. Keeps the prompt (and its cache) identical to a tool-calling turn — e.g. a summarisation step. |
11
+ | `"required"` | The model must call at least one tool. |
12
+ | `{"type": "function", "function": {"name": "X"}}` | The model must call tool `X`. |
13
+
14
+ ## Anthropic models via OpenRouter
15
+
16
+ OpenRouter translates the request into Anthropic's format
17
+ ([Anthropic `tool_choice`](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools#forcing-tool-use)):
18
+ `"required"` becomes `{"type": "any"}` and a named function becomes
19
+ `{"type": "tool", "name": "X"}`.
20
+
21
+ Anthropic does not support forced tool use (`any`, `tool`) on **Claude Opus
22
+ 5.5, Sonnet 5.5, Fable 5.1**, nor with manual extended thinking. These calls
23
+ fail with a 400 — `auto` and `none` still work. Haiku 5.5 and the 4.x models
24
+ accept it. Observed 2026-10-08 through OpenRouter:
25
+
26
+ ```
27
+ tool_choice: type "tool" and "any" are not supported for this model.
28
+ ```
29
+
30
+ The message names Anthropic's types, not the `"required"` that was sent: see
31
+ [openrouter-errors.md](openrouter-errors.md).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dot-inference"
3
- version = "2.0.3"
3
+ version = "2.2.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