digline-openai 0.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.
@@ -0,0 +1,41 @@
1
+ # Python bytecode and build output
2
+ __pycache__/
3
+ *.py[cod]
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+
8
+ # Environment. Recreated by `uv sync`; it pins absolute paths, so it must
9
+ # never be committed.
10
+ .venv/
11
+ .env
12
+
13
+ # Tool caches. Each already drops its own `.gitignore`; listed here so a
14
+ # fresh clone is clean before the tools have run once.
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ .mypy_cache/
18
+
19
+ # IDE. Excluded because the project files carry machine-specific SDK paths
20
+ # (`digline.iml` names the interpreter by absolute path). Drop these two lines
21
+ # to version the shared part, and keep ignoring `.idea/workspace.xml`.
22
+ .idea/
23
+ *.iml
24
+
25
+ # Local Claude Code settings. `.claude/settings.json`, if it appears, is shared
26
+ # and stays versioned. `CLAUDE.local.md` is the personal working agreement —
27
+ # how I want to be worked with — as against `CLAUDE.md`, which is the project.
28
+ .claude/settings.local.json
29
+ CLAUDE.local.md
30
+
31
+ # macOS
32
+ .DS_Store
33
+
34
+ # Working material that stays local and is not part of the package.
35
+ private/
36
+
37
+ # NOT ignored: `.digline/`. Decision 2 — baselines are versioned, run
38
+ # artifacts are not — and the split is enforced one level down, by the
39
+ # `.gitignore` the store itself writes into `.digline/` (`*/runs/`).
40
+ # Ignoring `.digline/` here would take the baselines out of git with it.
41
+ to-publish/
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.5
2
+ Name: digline-openai
3
+ Version: 0.1.0
4
+ Summary: OpenAI target and judges for digline, at any OpenAI-compatible endpoint.
5
+ Author-email: Alessandro Prandini <alessandro.prandini@ict-group.it>
6
+ License-Expression: Apache-2.0
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Topic :: Software Development :: Testing
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.12
14
+ Requires-Dist: digline>=0.1.3
15
+ Requires-Dist: openai>=1.40
16
+ Description-Content-Type: text/markdown
17
+
18
+ # digline-openai
19
+
20
+ An [OpenAI](https://openai.com) target **and judges** for
21
+ [digline](https://pypi.org/project/digline/): a prompt file goes in, a priced
22
+ `Response` comes out — at any OpenAI-compatible endpoint.
23
+
24
+ ```sh
25
+ pip install digline-openai
26
+ ```
27
+
28
+ ## One argument, three providers
29
+
30
+ The wire protocol is the same everywhere, so `base_url` is the only thing that
31
+ changes. **OpenAI** — the key is read by the SDK from `OPENAI_API_KEY`, and this
32
+ package never touches your environment:
33
+
34
+ ```python
35
+ from digline_openai import OpenAITarget
36
+
37
+ target = OpenAITarget("prompts/answer.md", model="gpt-5", max_tokens=1024)
38
+ ```
39
+
40
+ **Azure OpenAI** — your resource's v1 endpoint, with the key passed explicitly
41
+ because Azure names its variable something else:
42
+
43
+ ```python
44
+ import os
45
+ from digline_openai import OpenAITarget
46
+
47
+ target = OpenAITarget(
48
+ "prompts/answer.md",
49
+ model="gpt-4.1",
50
+ max_tokens=1024,
51
+ base_url="https://my-resource.openai.azure.com/openai/v1",
52
+ api_key=os.environ["AZURE_OPENAI_API_KEY"],
53
+ )
54
+ ```
55
+
56
+ **Ollama** — no key at all, and a model that costs nothing because you are the
57
+ one hosting it:
58
+
59
+ ```python
60
+ from digline_openai import OpenAITarget, free
61
+
62
+ target = OpenAITarget(
63
+ "prompts/answer.md",
64
+ model="llama3.2",
65
+ max_tokens=1024,
66
+ base_url="http://localhost:11434/v1",
67
+ pricing=free("llama3.2"),
68
+ )
69
+ ```
70
+
71
+ The same target covers OpenRouter, Groq, Together and a vLLM in your own VPC. Nothing here is a gateway or an abstraction layer: it is the `openai` SDK
72
+ with its own `base_url` argument, which is what that argument is for.
73
+
74
+ ## The judge runs in your perimeter too
75
+
76
+ A plugin is a target **and** a judge ([ADR
77
+ 0004](https://github.com/digline/digline/blob/main/docs/adr/0004-every-plugin-is-a-target-and-a-judge.md)).
78
+ The point is not convenience: what a judge is sent is the model's *output*, so a
79
+ judge that lives at somebody else's API takes the payload out of the perimeter
80
+ it was generated in, and no setting in the suite would say so.
81
+
82
+ ```python
83
+ from digline.core import LlmRubric
84
+ from digline_openai import OpenAIJudge
85
+
86
+ judge = OpenAIJudge(model="gpt-5-mini")
87
+ rubric = LlmRubric(
88
+ rubric="The answer is one sentence and cites the passage it came from.",
89
+ judge=judge,
90
+ threshold=0.8,
91
+ tolerance=0.05,
92
+ )
93
+ ```
94
+
95
+ `Faithfulness` asks a judge to decompose rather than to score — how many claims
96
+ the output makes, how many the context supports — so it takes the other one:
97
+
98
+ ```python
99
+ from digline.core import Faithfulness
100
+ from digline_openai import OpenAIClaimJudge
101
+
102
+ faithful = Faithfulness(
103
+ judge=OpenAIClaimJudge(model="gpt-5-mini"),
104
+ threshold=0.9,
105
+ tolerance=0.05,
106
+ )
107
+ ```
108
+
109
+ Both take the same `base_url` and `api_key` as the target, so judging an Ollama
110
+ run on that same Ollama is one argument:
111
+
112
+ ```python
113
+ from digline_openai import OpenAIJudge, free
114
+
115
+ local = OpenAIJudge(
116
+ model="llama3.2",
117
+ base_url="http://localhost:11434/v1",
118
+ pricing=free("llama3.2"),
119
+ )
120
+ ```
121
+
122
+ ## What judging cost
123
+
124
+ The target's cost lands on the `Response` and in the run. A judge's does not —
125
+ it is counted on the judge, and it is not reset:
126
+
127
+ ```python
128
+ from digline_openai import OpenAIJudge
129
+
130
+ judge = OpenAIJudge(model="gpt-5-mini")
131
+ print(f"{judge.calls} judgements, {judge.spent_usd:.4f} USD, {judge.latency_ms:.0f} ms")
132
+ ```
133
+
134
+ A suite with `samples=5` and `Repeated(n=3)` makes fifteen judging calls per
135
+ case, so this is not a rounding error. For a per-run figure, read it before and
136
+ after and subtract. It is in-process only today, and ADR 0004 §3 says what it
137
+ would take to put it in the report.
138
+
139
+ ## The details that bite
140
+
141
+ **Cached tokens.** OpenAI counts cached prompt tokens *inside* `prompt_tokens`.
142
+ They are subtracted before pricing, so the discounted half is not also billed at
143
+ the full rate — the opposite convention to Anthropic, and getting it wrong is
144
+ invisible in the direction of good news.
145
+
146
+ **`max_tokens` vs `max_completion_tokens`.** The official API rejects
147
+ `max_tokens` for GPT-5 and the o-series; most compatible servers accept it and
148
+ silently ignore `max_completion_tokens`, which generates without a cap and bills
149
+ for it. So: `max_completion_tokens` when `base_url` is unset, `max_tokens`
150
+ otherwise. Override with `token_param="max_tokens"` when your server disagrees.
151
+
152
+ **JSON.** The judges ask for `{"type": "json_object"}` where it is supported. A
153
+ provider that refuses it is retried once without it, the fallback is remembered,
154
+ and the reply is parsed leniently either way — a fenced block or a sentence in
155
+ front of the object both read correctly.
156
+
157
+ **Prices.** `OPENAI_PRICING` carries the day it was copied and is one argument
158
+ to replace; an unknown model raises at `preflight` rather than costing nothing.
159
+ `free("llama3.2")` is how you say a self-hosted model really is free, out loud.
160
+
161
+ **Keys.** Passed explicitly or resolved by the SDK from the environment — this
162
+ package contains no `os.environ` and no `getenv`, and a test enforces it. Only
163
+ when `base_url` is custom *and* the SDK found nothing does the client fall back
164
+ to the obviously-fake `digline-no-key`, for local servers that ignore it.
165
+
166
+ Apache-2.0. Docs: [digline/digline](https://github.com/digline/digline).
@@ -0,0 +1,149 @@
1
+ # digline-openai
2
+
3
+ An [OpenAI](https://openai.com) target **and judges** for
4
+ [digline](https://pypi.org/project/digline/): a prompt file goes in, a priced
5
+ `Response` comes out — at any OpenAI-compatible endpoint.
6
+
7
+ ```sh
8
+ pip install digline-openai
9
+ ```
10
+
11
+ ## One argument, three providers
12
+
13
+ The wire protocol is the same everywhere, so `base_url` is the only thing that
14
+ changes. **OpenAI** — the key is read by the SDK from `OPENAI_API_KEY`, and this
15
+ package never touches your environment:
16
+
17
+ ```python
18
+ from digline_openai import OpenAITarget
19
+
20
+ target = OpenAITarget("prompts/answer.md", model="gpt-5", max_tokens=1024)
21
+ ```
22
+
23
+ **Azure OpenAI** — your resource's v1 endpoint, with the key passed explicitly
24
+ because Azure names its variable something else:
25
+
26
+ ```python
27
+ import os
28
+ from digline_openai import OpenAITarget
29
+
30
+ target = OpenAITarget(
31
+ "prompts/answer.md",
32
+ model="gpt-4.1",
33
+ max_tokens=1024,
34
+ base_url="https://my-resource.openai.azure.com/openai/v1",
35
+ api_key=os.environ["AZURE_OPENAI_API_KEY"],
36
+ )
37
+ ```
38
+
39
+ **Ollama** — no key at all, and a model that costs nothing because you are the
40
+ one hosting it:
41
+
42
+ ```python
43
+ from digline_openai import OpenAITarget, free
44
+
45
+ target = OpenAITarget(
46
+ "prompts/answer.md",
47
+ model="llama3.2",
48
+ max_tokens=1024,
49
+ base_url="http://localhost:11434/v1",
50
+ pricing=free("llama3.2"),
51
+ )
52
+ ```
53
+
54
+ The same target covers OpenRouter, Groq, Together and a vLLM in your own VPC. Nothing here is a gateway or an abstraction layer: it is the `openai` SDK
55
+ with its own `base_url` argument, which is what that argument is for.
56
+
57
+ ## The judge runs in your perimeter too
58
+
59
+ A plugin is a target **and** a judge ([ADR
60
+ 0004](https://github.com/digline/digline/blob/main/docs/adr/0004-every-plugin-is-a-target-and-a-judge.md)).
61
+ The point is not convenience: what a judge is sent is the model's *output*, so a
62
+ judge that lives at somebody else's API takes the payload out of the perimeter
63
+ it was generated in, and no setting in the suite would say so.
64
+
65
+ ```python
66
+ from digline.core import LlmRubric
67
+ from digline_openai import OpenAIJudge
68
+
69
+ judge = OpenAIJudge(model="gpt-5-mini")
70
+ rubric = LlmRubric(
71
+ rubric="The answer is one sentence and cites the passage it came from.",
72
+ judge=judge,
73
+ threshold=0.8,
74
+ tolerance=0.05,
75
+ )
76
+ ```
77
+
78
+ `Faithfulness` asks a judge to decompose rather than to score — how many claims
79
+ the output makes, how many the context supports — so it takes the other one:
80
+
81
+ ```python
82
+ from digline.core import Faithfulness
83
+ from digline_openai import OpenAIClaimJudge
84
+
85
+ faithful = Faithfulness(
86
+ judge=OpenAIClaimJudge(model="gpt-5-mini"),
87
+ threshold=0.9,
88
+ tolerance=0.05,
89
+ )
90
+ ```
91
+
92
+ Both take the same `base_url` and `api_key` as the target, so judging an Ollama
93
+ run on that same Ollama is one argument:
94
+
95
+ ```python
96
+ from digline_openai import OpenAIJudge, free
97
+
98
+ local = OpenAIJudge(
99
+ model="llama3.2",
100
+ base_url="http://localhost:11434/v1",
101
+ pricing=free("llama3.2"),
102
+ )
103
+ ```
104
+
105
+ ## What judging cost
106
+
107
+ The target's cost lands on the `Response` and in the run. A judge's does not —
108
+ it is counted on the judge, and it is not reset:
109
+
110
+ ```python
111
+ from digline_openai import OpenAIJudge
112
+
113
+ judge = OpenAIJudge(model="gpt-5-mini")
114
+ print(f"{judge.calls} judgements, {judge.spent_usd:.4f} USD, {judge.latency_ms:.0f} ms")
115
+ ```
116
+
117
+ A suite with `samples=5` and `Repeated(n=3)` makes fifteen judging calls per
118
+ case, so this is not a rounding error. For a per-run figure, read it before and
119
+ after and subtract. It is in-process only today, and ADR 0004 §3 says what it
120
+ would take to put it in the report.
121
+
122
+ ## The details that bite
123
+
124
+ **Cached tokens.** OpenAI counts cached prompt tokens *inside* `prompt_tokens`.
125
+ They are subtracted before pricing, so the discounted half is not also billed at
126
+ the full rate — the opposite convention to Anthropic, and getting it wrong is
127
+ invisible in the direction of good news.
128
+
129
+ **`max_tokens` vs `max_completion_tokens`.** The official API rejects
130
+ `max_tokens` for GPT-5 and the o-series; most compatible servers accept it and
131
+ silently ignore `max_completion_tokens`, which generates without a cap and bills
132
+ for it. So: `max_completion_tokens` when `base_url` is unset, `max_tokens`
133
+ otherwise. Override with `token_param="max_tokens"` when your server disagrees.
134
+
135
+ **JSON.** The judges ask for `{"type": "json_object"}` where it is supported. A
136
+ provider that refuses it is retried once without it, the fallback is remembered,
137
+ and the reply is parsed leniently either way — a fenced block or a sentence in
138
+ front of the object both read correctly.
139
+
140
+ **Prices.** `OPENAI_PRICING` carries the day it was copied and is one argument
141
+ to replace; an unknown model raises at `preflight` rather than costing nothing.
142
+ `free("llama3.2")` is how you say a self-hosted model really is free, out loud.
143
+
144
+ **Keys.** Passed explicitly or resolved by the SDK from the environment — this
145
+ package contains no `os.environ` and no `getenv`, and a test enforces it. Only
146
+ when `base_url` is custom *and* the SDK found nothing does the client fall back
147
+ to the obviously-fake `digline-no-key`, for local servers that ignore it.
148
+
149
+ Apache-2.0. Docs: [digline/digline](https://github.com/digline/digline).
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "digline-openai"
3
+ version = "0.1.0"
4
+ description = "OpenAI target and judges for digline, at any OpenAI-compatible endpoint."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ authors = [
9
+ { name = "Alessandro Prandini", email = "alessandro.prandini@ict-group.it" },
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Software Development :: Testing",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ # Pinned to a version that exists, like the Anthropic plugin: unpinned was
21
+ # fine while the two were built together in one workspace; published, it
22
+ # would let a resolver pick something older than the API this plugin is
23
+ # written against — `JudgeBase` and its two subclasses are 0.1.3.
24
+ "digline>=0.1.3",
25
+ # The SDK and nothing else. No LiteLLM, no gateway, no abstraction over the
26
+ # abstraction: `base_url` is what makes one SDK cover every compatible
27
+ # provider, and a layer on top would only add a second thing to be wrong.
28
+ "openai>=1.40",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["hatchling>=1.27"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/digline_openai"]
37
+
38
+ [tool.uv.sources]
39
+ digline = { workspace = true }
@@ -0,0 +1,25 @@
1
+ """OpenAI target and judges for digline, at any OpenAI-compatible endpoint.
2
+
3
+ Installed beside digline, never inside it: `pip install digline` must not pull
4
+ somebody's HTTP client along with it.
5
+
6
+ A plugin is a target **and** a judge (ADR 0004), so a suite can generate and
7
+ judge in one perimeter, with one key and one price list — including when that
8
+ perimeter is an Azure deployment, a vLLM in a VPC or an Ollama on a laptop.
9
+ """
10
+
11
+ from digline_openai.client import NO_KEY, OpenAIChat
12
+ from digline_openai.judge import OpenAIClaimJudge, OpenAIJudge
13
+ from digline_openai.pricing import OPENAI_PRICING, PRICES_READ_ON, free
14
+ from digline_openai.target import OpenAITarget
15
+
16
+ __all__ = [
17
+ "NO_KEY",
18
+ "OPENAI_PRICING",
19
+ "PRICES_READ_ON",
20
+ "OpenAIChat",
21
+ "OpenAIClaimJudge",
22
+ "OpenAIJudge",
23
+ "OpenAITarget",
24
+ "free",
25
+ ]
@@ -0,0 +1,229 @@
1
+ """The client half: one chat call, shared by the target and the two judges.
2
+
3
+ The SDK is imported on first use, not at module scope. A suite that only wants
4
+ to be *loaded* — `digline list`, a preflight, a test — should not need `openai`
5
+ installed, and lazy import is what makes that true.
6
+
7
+ **No key is read here.** There is no `os.environ` and no `getenv` in this
8
+ package, and the test suite enforces it. What the SDK reads on its own is the
9
+ SDK's business; see `build_client` for the one place where an absent key is
10
+ tolerated, and why it is only reachable behind a custom `base_url`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping, Sequence
16
+ from typing import Any, Literal
17
+
18
+ from digline.targets import Pricing, Usage
19
+
20
+ __all__ = ["NO_KEY", "OpenAIChat", "TokenParam", "build_client", "usage_of"]
21
+
22
+ #: Passed as the key when — and only when — a custom `base_url` is set and the
23
+ #: SDK found nothing in the environment. Ollama and most self-hosted servers do
24
+ #: not look at it; anything that does rejects it with an authentication error,
25
+ #: which is the right error. It is written to be unmistakably not a credential:
26
+ #: whoever finds it in a log has found a placeholder, not a leak.
27
+ NO_KEY = "digline-no-key"
28
+
29
+ #: Which argument carries the output cap. See `OpenAIChat.complete`.
30
+ TokenParam = Literal["auto", "max_tokens", "max_completion_tokens"]
31
+
32
+
33
+ def build_client(base_url: str | None, api_key: str | None) -> Any:
34
+ """The SDK client, with the key resolution rule of ADR 0004 §5.
35
+
36
+ Three cases, in this order, and the order is the whole trick:
37
+
38
+ 1. a key was passed — it is used;
39
+ 2. no key and the **official** endpoint — `None` goes to the SDK, which
40
+ resolves `OPENAI_API_KEY` itself and raises its own message if there is
41
+ none. That message is clearer than anything this package could write,
42
+ and it keeps the environment out of our source;
43
+ 3. no key, a **custom** `base_url`, and the SDK found nothing — the client
44
+ is rebuilt with `NO_KEY`, because a local server has no key to give.
45
+
46
+ Case 3 is reached only *after* the SDK has looked, so pointing at OpenRouter
47
+ with `OPENAI_API_KEY` set still authenticates with the real key. This
48
+ function never learns whether one exists.
49
+ """
50
+ import openai
51
+
52
+ kwargs: dict[str, Any] = {}
53
+ if base_url is not None:
54
+ kwargs["base_url"] = base_url
55
+ if api_key is not None:
56
+ kwargs["api_key"] = api_key
57
+ try:
58
+ return openai.OpenAI(**kwargs)
59
+ except openai.OpenAIError:
60
+ if api_key is not None or base_url is None:
61
+ raise
62
+ return openai.OpenAI(base_url=base_url, api_key=NO_KEY)
63
+
64
+
65
+ def usage_of(reply: Any, model: str, pricing: Pricing) -> Usage:
66
+ """Tokens out of a chat completion, with the cached ones subtracted.
67
+
68
+ OpenAI counts cached prompt tokens **inside** `prompt_tokens` — the opposite
69
+ of Anthropic, where a cache write is not in `input_tokens` at all (friction
70
+ 25). Adding the two straight would bill the cached half twice, at the full
71
+ rate and again at the discounted one, so the cached count comes off the
72
+ input before the `Usage` is built.
73
+
74
+ A provider that reports no usage at all is refused, unless the model is
75
+ priced at zero anyway — which is to say unless you told us, with `free()`,
76
+ that this one costs nothing. Anything else would report a run as cheaper
77
+ than it was, and that is the failure that reads as good news.
78
+ """
79
+ usage = getattr(reply, "usage", None)
80
+ if usage is None:
81
+ if _is_free(model, pricing):
82
+ return Usage(input_tokens=0, output_tokens=0)
83
+ raise ValueError(
84
+ f"the provider returned no usage for model {model!r}, so this call "
85
+ "cannot be priced. If it is a model you host and it costs nothing, "
86
+ "say so: `pricing=free(...)`"
87
+ )
88
+ prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
89
+ details = getattr(usage, "prompt_tokens_details", None)
90
+ cached = int(getattr(details, "cached_tokens", 0) or 0)
91
+ return Usage(
92
+ input_tokens=max(prompt_tokens - cached, 0),
93
+ output_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
94
+ cache_read_tokens=cached,
95
+ # No cache-write charge on this API: there is nothing to count, which
96
+ # is why the price list leaves the rate at `None` rather than at zero.
97
+ cache_write_tokens=0,
98
+ )
99
+
100
+
101
+ def _is_free(model: str, pricing: Pricing) -> bool:
102
+ price = pricing.per_model.get(model)
103
+ return price is not None and not any(
104
+ (
105
+ price.input_per_mtok,
106
+ price.output_per_mtok,
107
+ price.cache_read_per_mtok,
108
+ price.cache_write_per_mtok,
109
+ )
110
+ )
111
+
112
+
113
+ class OpenAIChat:
114
+ """A lazily built client and the one call every OpenAI-compatible server has.
115
+
116
+ Held by the target and by both judges, so the key resolution, the token
117
+ argument and the `response_format` fallback are decided once for the whole
118
+ package rather than three times.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ *,
124
+ base_url: str | None = None,
125
+ api_key: str | None = None,
126
+ client: Any = None,
127
+ ) -> None:
128
+ self.base_url = base_url
129
+ self._api_key = api_key
130
+ self._injected = client
131
+ #: Set once, the first time a provider refuses `response_format`. The
132
+ #: fallback is remembered rather than rediscovered: one wasted call per
133
+ #: process, not one per judgement.
134
+ self.json_mode_refused = False
135
+
136
+ def __repr__(self) -> str:
137
+ """The endpoint, never the key.
138
+
139
+ Explicit because the default `repr` is only safe by accident, and a
140
+ `repr` is what ends up in a pytest failure, a log line and a traceback.
141
+ """
142
+ return f"{type(self).__name__}(base_url={self.base_url!r})"
143
+
144
+ def client(self) -> Any:
145
+ if self._injected is None:
146
+ self._injected = build_client(self.base_url, self._api_key)
147
+ return self._injected
148
+
149
+ def token_argument(self, token_param: TokenParam) -> str:
150
+ """Which of the two names carries the output cap.
151
+
152
+ `"auto"` is `max_completion_tokens` on the official endpoint and
153
+ `max_tokens` everywhere else, and the asymmetry is not ours: the
154
+ official API **rejects** `max_tokens` for the GPT-5 and o-series
155
+ models, while most compatible servers accept `max_tokens` and quietly
156
+ *ignore* `max_completion_tokens` — which does not fail, it just
157
+ generates without a cap and bills for it.
158
+
159
+ A guess in one direction is an error you see; in the other, a cost you
160
+ do not. Pass the name explicitly when your server disagrees.
161
+ """
162
+ if token_param != "auto":
163
+ return token_param
164
+ return "max_tokens" if self.base_url is not None else "max_completion_tokens"
165
+
166
+ def complete(
167
+ self,
168
+ *,
169
+ model: str,
170
+ messages: Sequence[Mapping[str, Any]],
171
+ max_tokens: int,
172
+ pricing: Pricing,
173
+ temperature: float | None = None,
174
+ response_format: Mapping[str, Any] | None = None,
175
+ token_param: TokenParam = "auto",
176
+ extra_body: Mapping[str, Any] | None = None,
177
+ ) -> tuple[str, Usage]:
178
+ """One chat completion: the text and what it cost in tokens.
179
+
180
+ `response_format` is sent when asked for and **never required** (ADR
181
+ 0004 §4). A provider that rejects it — Ollama does, some vLLM builds do
182
+ — is retried once without it, and the fallback is remembered. That
183
+ means one call's worth of latency the first time, and a judge that
184
+ works on an endpoint nobody tested it against.
185
+ """
186
+ request: dict[str, Any] = {
187
+ "model": model,
188
+ "messages": list(messages),
189
+ self.token_argument(token_param): max_tokens,
190
+ }
191
+ if temperature is not None:
192
+ request["temperature"] = temperature
193
+ if extra_body:
194
+ request.update(extra_body)
195
+
196
+ wants_json = response_format is not None and not self.json_mode_refused
197
+ if wants_json and response_format is not None:
198
+ request["response_format"] = dict(response_format)
199
+
200
+ try:
201
+ reply = self.client().chat.completions.create(**request)
202
+ except Exception:
203
+ # Retried only when `response_format` was in the request: without
204
+ # it there is nothing to fall back to, and swallowing the exception
205
+ # would hide an auth or a rate-limit error behind a second identical
206
+ # failure. With it, the second attempt either works or raises the
207
+ # error that was really there.
208
+ if not wants_json:
209
+ raise
210
+ self.json_mode_refused = True
211
+ del request["response_format"]
212
+ reply = self.client().chat.completions.create(**request)
213
+
214
+ return _text_of(reply), usage_of(reply, model, pricing)
215
+
216
+
217
+ def _text_of(reply: Any) -> str:
218
+ choices: Any = getattr(reply, "choices", None) or []
219
+ if not choices:
220
+ raise ValueError(
221
+ "the provider returned no choices: there is no output to judge or "
222
+ "to assert on"
223
+ )
224
+ message: Any = getattr(choices[0], "message", None)
225
+ # `None` rather than missing when the model produced nothing — a refusal, or
226
+ # a cap hit before the first token. Empty text is an output the assertions
227
+ # can fail; an exception here would make it an `error` instead, which says
228
+ # the run could not be judged rather than that the model said nothing.
229
+ return str(getattr(message, "content", None) or "")