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.
- digline_openai-0.1.0/.gitignore +41 -0
- digline_openai-0.1.0/PKG-INFO +166 -0
- digline_openai-0.1.0/README.md +149 -0
- digline_openai-0.1.0/pyproject.toml +39 -0
- digline_openai-0.1.0/src/digline_openai/__init__.py +25 -0
- digline_openai-0.1.0/src/digline_openai/client.py +229 -0
- digline_openai-0.1.0/src/digline_openai/judge.py +120 -0
- digline_openai-0.1.0/src/digline_openai/pricing.py +158 -0
- digline_openai-0.1.0/src/digline_openai/py.typed +0 -0
- digline_openai-0.1.0/src/digline_openai/target.py +103 -0
- digline_openai-0.1.0/tests/_fakes.py +93 -0
- digline_openai-0.1.0/tests/conftest.py +20 -0
- digline_openai-0.1.0/tests/test_openai_judge.py +357 -0
- digline_openai-0.1.0/tests/test_openai_readme.py +114 -0
- digline_openai-0.1.0/tests/test_openai_target.py +449 -0
|
@@ -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 "")
|