decision-toolkit 0.3.1__tar.gz → 0.4.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 (98) hide show
  1. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/CHANGELOG.md +19 -1
  2. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/PKG-INFO +8 -8
  3. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/README.md +7 -7
  4. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/EXAMPLES.md +3 -3
  5. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/RELEASE.md +11 -8
  6. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/REST.md +19 -19
  7. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/VARIANTS.md +15 -10
  8. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/pyproject.toml +1 -1
  9. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/__init__.py +1 -1
  10. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/catalog.py +18 -0
  11. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/contracts/openai_decisions_beta.py +1 -1
  12. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/contracts/systemone_v1.py +0 -1
  13. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/registry.py +8 -0
  14. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/server.py +69 -16
  15. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_server.py +52 -8
  16. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/uv.lock +1 -1
  17. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/.github/workflows/ci.yml +0 -0
  18. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/.github/workflows/docs.yml +0 -0
  19. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/.github/workflows/release.yml +0 -0
  20. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/.gitignore +0 -0
  21. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/CONTRIBUTING.md +0 -0
  22. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/LICENSE +0 -0
  23. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/SECURITY.md +0 -0
  24. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/THIRD_PARTY_NOTICES.md +0 -0
  25. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/bench/README.md +0 -0
  26. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/bench/__init__.py +0 -0
  27. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/bench/load.py +0 -0
  28. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/bench/results/2026-10-20-calibration.json +0 -0
  29. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/bench/results/2026-10-20-load.json +0 -0
  30. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/bench/results/2026-10-20-smoke.json +0 -0
  31. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/bench/run.py +0 -0
  32. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/bench/suites/calibration.jsonl +0 -0
  33. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/bench/suites/smoke.jsonl +0 -0
  34. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/BENCH.md +0 -0
  35. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/CATALOG_PROPOSAL.md +0 -0
  36. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/CLI.md +0 -0
  37. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/CORE.md +0 -0
  38. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/OPERATIONS.md +0 -0
  39. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/ROADMAP.md +0 -0
  40. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/index.md +0 -0
  41. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/models/README.md +0 -0
  42. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/models/gliner2.5-decide.md +0 -0
  43. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/models/gliner2.5-multi.md +0 -0
  44. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/models/julia-1.md +0 -0
  45. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/models/laya-multilingual.md +0 -0
  46. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/models/laya.md +0 -0
  47. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/models/open-jev-deberta-v3-large.md +0 -0
  48. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/models/opendecider-nano.md +0 -0
  49. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/docs/models/strands-decider-2b.md +0 -0
  50. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/__main__.py +0 -0
  51. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/adapters/__init__.py +0 -0
  52. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/adapters/base.py +0 -0
  53. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/adapters/gliner2.py +0 -0
  54. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/adapters/gliner2_decide.py +0 -0
  55. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/adapters/julia.py +0 -0
  56. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/adapters/laya.py +0 -0
  57. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/adapters/opendecider.py +0 -0
  58. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/adapters/openjev.py +0 -0
  59. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/adapters/strands_decider.py +0 -0
  60. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/calibration.py +0 -0
  61. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/cli.py +0 -0
  62. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/config.py +0 -0
  63. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/contracts/__init__.py +0 -0
  64. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/contracts/common.py +0 -0
  65. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/contracts/mapping.py +0 -0
  66. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/decision_model.py +0 -0
  67. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/engine/__init__.py +0 -0
  68. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/errors.py +0 -0
  69. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/limits.py +0 -0
  70. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/metrics.py +0 -0
  71. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/models.py +0 -0
  72. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/observability.py +0 -0
  73. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/py.typed +0 -0
  74. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/results.py +0 -0
  75. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/schema.py +0 -0
  76. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/src/decision_toolkit/tracing.py +0 -0
  77. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/__init__.py +0 -0
  78. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/conftest.py +0 -0
  79. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_adapters_gliner2.py +0 -0
  80. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_adapters_gliner2_decide.py +0 -0
  81. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_adapters_julia.py +0 -0
  82. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_adapters_laya.py +0 -0
  83. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_adapters_opendecider.py +0 -0
  84. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_adapters_openjev.py +0 -0
  85. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_adapters_strands.py +0 -0
  86. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_bench.py +0 -0
  87. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_catalog.py +0 -0
  88. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_cli.py +0 -0
  89. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_config.py +0 -0
  90. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_contracts_conformance.py +0 -0
  91. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_core_phase1.py +0 -0
  92. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_decision_model.py +0 -0
  93. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_fuzz_rest.py +0 -0
  94. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_integration_gliner2.py +0 -0
  95. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_models.py +0 -0
  96. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_operations.py +0 -0
  97. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_results.py +0 -0
  98. {decision_toolkit-0.3.1 → decision_toolkit-0.4.0}/tests/test_schema.py +0 -0
@@ -1,8 +1,25 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project are documented here. Version `0.3.1` is
3
+ All notable changes to this project are documented here. Version `0.4.0` is
4
4
  published on PyPI.
5
5
 
6
+ ## [0.4.0] — 2026-10-21
7
+
8
+ REST variant-selection correction. Quantizations are selected through standard
9
+ model aliases (`id@variant`), not a non-standard `variant` body field. The same
10
+ REST process can load two variants of one base model in separate registry slots.
11
+
12
+ ### Fixed
13
+
14
+ - Removed `variant` from System One and OpenAI request bodies; standard clients
15
+ now see only the public request fields.
16
+ - Added `catalog-id@variant` model aliases to `/v1/models` and
17
+ `/v1/models/{id}`. The standard `model` field selects the alias.
18
+ - Registry keys and slots are per alias, so `laya-multilingual@fp16` and
19
+ `laya-multilingual@fp32` load independently and concurrently.
20
+ - Updated README, REST, variants, examples and CLI help so REST never shows a
21
+ variant field in a standard request body.
22
+
6
23
  ## [0.3.1] — 2026-10-21
7
24
 
8
25
  Catalog UX patch: `dtk models list` now prints one row per variant, with
@@ -192,6 +209,7 @@ README correction. No code or model behavior changed.
192
209
  - Typed `DecisionModel` API with lifecycle, batching, async, usage, warnings,
193
210
  normalized confidence, capabilities, structured events and atomic model store.
194
211
 
212
+ [0.4.0]: https://pypi.org/project/decision-toolkit/0.4.0/
195
213
  [0.3.1]: https://pypi.org/project/decision-toolkit/0.3.1/
196
214
  [0.3.0]: https://pypi.org/project/decision-toolkit/0.3.0/
197
215
  [0.2.1]: https://pypi.org/project/decision-toolkit/0.2.1/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: decision-toolkit
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Local-first toolkit for open-weights decision models: one ONNX engine, one adapter per model contract, typed choice/yes-no/score decisions with probabilities on CPU.
5
5
  Project-URL: Homepage, https://github.com/gia-uh/decision-toolkit
6
6
  Project-URL: Repository, https://github.com/gia-uh/decision-toolkit
@@ -59,7 +59,7 @@ and CLI. `dtk --help` and every subcommand help also include runnable examples.
59
59
  ## Installation
60
60
 
61
61
  ```bash
62
- uv pip install decision-toolkit # library + CLI (0.3.1 on PyPI)
62
+ uv pip install decision-toolkit # library + CLI (0.4.0 on PyPI)
63
63
  uv pip install "decision-toolkit[onnx]" # + onnxruntime, tokenizers (the model runtime)
64
64
  uv pip install "decision-toolkit[server]" # + REST server (FastAPI)
65
65
  ```
@@ -67,7 +67,7 @@ uv pip install "decision-toolkit[server]" # + REST server (FastAPI)
67
67
  The project is managed with [uv](https://docs.astral.sh/uv/) and ships a committed `uv.lock`. Published
68
68
  wheels remain compatible with `pip` for environments that do not use uv.
69
69
 
70
- PyPI: <https://pypi.org/project/decision-toolkit/0.3.1/>. See
70
+ PyPI: <https://pypi.org/project/decision-toolkit/0.4.0/>. See
71
71
  [docs/RELEASE.md](https://github.com/gia-uh/decision-toolkit/blob/main/docs/RELEASE.md)
72
72
  for the Trusted Publishing flow and clean-install checks.
73
73
 
@@ -248,11 +248,11 @@ dtk models current --json # {"current": "...", "variant": "fp32"}
248
248
  dtk decide --model gliner2.5-multi --variant fp16 --state "..." --q 'cat=choice:a|b'
249
249
  ```
250
250
 
251
- From Python: `DecisionModel("gliner2.5-multi", variant="fp32")`. REST selects
252
- the same way with `"variant": "fp32"` in the request body and returns the choice
253
- under `x_decision_toolkit.variant`. The variant must already be downloaded. If a
254
- variant is omitted, the catalog default is used unless `dtk models use` selected
255
- another one.
251
+ From Python: `DecisionModel("gliner2.5-multi", variant="fp32")`. In REST,
252
+ quantizations are **model aliases**: use `"model": "gliner2.5-multi@fp32"` in the
253
+ standard `model` field. The base id uses the server/persisted default. There is
254
+ no `variant` field in the request body; `/v1/models` lists both the base model
255
+ and every alias, and two aliases can be loaded concurrently.
256
256
 
257
257
  A variant becomes default only after passing the parity gate (top-1 ≥ 99%,
258
258
  mean |Δp| ≤ 0.02, accuracy delta ≤ 1 pp against the fp32 build on a fixed
@@ -21,7 +21,7 @@ and CLI. `dtk --help` and every subcommand help also include runnable examples.
21
21
  ## Installation
22
22
 
23
23
  ```bash
24
- uv pip install decision-toolkit # library + CLI (0.3.1 on PyPI)
24
+ uv pip install decision-toolkit # library + CLI (0.4.0 on PyPI)
25
25
  uv pip install "decision-toolkit[onnx]" # + onnxruntime, tokenizers (the model runtime)
26
26
  uv pip install "decision-toolkit[server]" # + REST server (FastAPI)
27
27
  ```
@@ -29,7 +29,7 @@ uv pip install "decision-toolkit[server]" # + REST server (FastAPI)
29
29
  The project is managed with [uv](https://docs.astral.sh/uv/) and ships a committed `uv.lock`. Published
30
30
  wheels remain compatible with `pip` for environments that do not use uv.
31
31
 
32
- PyPI: <https://pypi.org/project/decision-toolkit/0.3.1/>. See
32
+ PyPI: <https://pypi.org/project/decision-toolkit/0.4.0/>. See
33
33
  [docs/RELEASE.md](https://github.com/gia-uh/decision-toolkit/blob/main/docs/RELEASE.md)
34
34
  for the Trusted Publishing flow and clean-install checks.
35
35
 
@@ -210,11 +210,11 @@ dtk models current --json # {"current": "...", "variant": "fp32"}
210
210
  dtk decide --model gliner2.5-multi --variant fp16 --state "..." --q 'cat=choice:a|b'
211
211
  ```
212
212
 
213
- From Python: `DecisionModel("gliner2.5-multi", variant="fp32")`. REST selects
214
- the same way with `"variant": "fp32"` in the request body and returns the choice
215
- under `x_decision_toolkit.variant`. The variant must already be downloaded. If a
216
- variant is omitted, the catalog default is used unless `dtk models use` selected
217
- another one.
213
+ From Python: `DecisionModel("gliner2.5-multi", variant="fp32")`. In REST,
214
+ quantizations are **model aliases**: use `"model": "gliner2.5-multi@fp32"` in the
215
+ standard `model` field. The base id uses the server/persisted default. There is
216
+ no `variant` field in the request body; `/v1/models` lists both the base model
217
+ and every alias, and two aliases can be loaded concurrently.
218
218
 
219
219
  A variant becomes default only after passing the parity gate (top-1 ≥ 99%,
220
220
  mean |Δp| ≤ 0.02, accuracy delta ≤ 1 pp against the fp32 build on a fixed
@@ -165,7 +165,8 @@ curl -s http://127.0.0.1:8000/v1/decisions \
165
165
  }' | jq
166
166
  ```
167
167
 
168
- `variant` is a decision-toolkit extension; omit it for a standard client.
168
+ Use the model alias `id@variant` for a specific quantization; the base id uses
169
+ the server's default. There is no `variant` field in the request body.
169
170
 
170
171
  ### Model per request and auth
171
172
 
@@ -176,8 +177,7 @@ curl -s http://127.0.0.1:8000/v1/systemone \
176
177
  -H "Authorization: Bearer secret" \
177
178
  -H "Content-Type: application/json" \
178
179
  -d '{
179
- "model": "gliner2.5-decide",
180
- "variant": "q4f16",
180
+ "model": "gliner2.5-multi@fp32",
181
181
  "state": "I want my money back.",
182
182
  "questions": {
183
183
  "intent": {"type": "choice", "instructions": "What is the request?",
@@ -10,7 +10,9 @@ and selection in the CLI and documentation; `0.1.7` aligned variant selection
10
10
  across the library, CLI and REST API; `0.2.0` corrected the REST conformance
11
11
  claims and wire shapes to the published TypeSafe and OpenAI schemas; `0.2.1`
12
12
  changed the public documentation URLs to this repository's Pages site; `0.3.0`
13
- added the Laya multilingual, Open-Jev DeBERTa v3 large and Strands Decider ONNX models; `0.3.1` made the catalog list one row per variant with exact commands.
13
+ added the Laya multilingual, Open-Jev DeBERTa v3 large and Strands Decider ONNX models; `0.3.1` made the catalog list one row per variant with exact commands; `0.4.0`
14
+ replaced the non-standard REST `variant` body field with standard model aliases
15
+ (`id@variant`).
14
16
 
15
17
  ## Local build and verification
16
18
 
@@ -37,7 +39,7 @@ Clean install test (no `PYTHONPATH`):
37
39
  ```bash
38
40
  uv venv --python 3.12 /tmp/dtk-wheel
39
41
  uv pip install --python /tmp/dtk-wheel/bin/python \
40
- dist/decision_toolkit-0.3.1-py3-none-any.whl
42
+ dist/decision_toolkit-0.4.0-py3-none-any.whl
41
43
  uv pip check --python /tmp/dtk-wheel/bin/python
42
44
  /tmp/dtk-wheel/bin/dtk --version
43
45
  /tmp/dtk-wheel/bin/dtk models list --json
@@ -48,7 +50,7 @@ With inference and server extras:
48
50
 
49
51
  ```bash
50
52
  uv pip install --python /tmp/dtk-wheel/bin/python \
51
- "dist/decision_toolkit-0.3.1-py3-none-any.whl[onnx,server,tracing]"
53
+ "dist/decision_toolkit-0.4.0-py3-none-any.whl[onnx,server,tracing]"
52
54
  /tmp/dtk-wheel/bin/dtk decide --model gliner2.5-decide ...
53
55
  /tmp/dtk-wheel/bin/dtk serve --model gliner2.5-decide --warmup --port 8016
54
56
  curl -s http://127.0.0.1:8016/health
@@ -82,8 +84,8 @@ No long-lived PyPI token is needed; the workflow uses GitHub OIDC
82
84
  4. Create and push an annotated tag:
83
85
 
84
86
  ```bash
85
- git tag -a v0.3.1 -m "decision-toolkit 0.3.1"
86
- git push origin v0.3.1
87
+ git tag -a v0.4.0 -m "decision-toolkit 0.4.0"
88
+ git push origin v0.4.0
87
89
  ```
88
90
 
89
91
  5. `.github/workflows/release.yml` builds once, publishes to **TestPyPI**, then
@@ -132,9 +134,9 @@ to TestPyPI without publishing to PyPI or creating a GitHub release.
132
134
 
133
135
  ## Published release
134
136
 
135
- `0.3.1` is published through Trusted Publishing on 2026-10-21:
137
+ `0.4.0` is published through Trusted Publishing on 2026-10-21:
136
138
 
137
- <https://pypi.org/project/decision-toolkit/0.3.1/>
139
+ <https://pypi.org/project/decision-toolkit/0.4.0/>
138
140
 
139
141
  `0.1.0` was the first release; `0.1.1` refreshed the PyPI description; `0.1.2`
140
142
  validated the OIDC TestPyPI → PyPI flow; `0.1.3` added post-publish install
@@ -144,7 +146,8 @@ published to TestPyPI only; `0.1.5` fixed TestPyPI resolution; `0.1.6` added
144
146
  complete variant/quantization visibility and CLI selection; `0.1.7` aligned
145
147
  selection across library, CLI and REST; `0.2.0` corrected the REST conformance
146
148
  claims and wire shapes; `0.2.1` changed the public documentation URLs; `0.3.0`
147
- added the three new ONNX decision models; `0.3.1` made the catalog list one row per variant.
149
+ added the three new ONNX decision models; `0.3.1` made the catalog list one row per variant; `0.4.0` moved REST variant
150
+ selection to standard model aliases.
148
151
 
149
152
  The tag-triggered workflow remains the preferred path for future releases once
150
153
  the TestPyPI/PyPI trusted publishers and protected environments are configured.
@@ -49,25 +49,25 @@ limitations listed below.
49
49
  Toolkit-only metadata is under `x_decision_toolkit`, never mixed into standard
50
50
  fields. `/v1/decision-models` is the rich, non-standard catalog endpoint.
51
51
 
52
- ## Variants (local extension)
53
-
54
- Neither TypeSafe nor OpenAI has a `variant` request field: the public APIs select
55
- a model by name. Quantization is a **decision-toolkit extension**:
56
-
57
- - Request `variant` wins when a local client sends it.
58
- - Otherwise, a server started with `dtk serve --variant` uses that default.
59
- - Otherwise, the variant selected with `dtk models use` is used when the server
60
- is serving the current model.
61
- - Otherwise, the catalog default (the `*` variant in `dtk models list`) is used.
62
-
63
- Standard clients should omit `variant`; they work with the server default. A
64
- standard-only integration can run one server per variant, or use the server's
65
- `--variant` setting. The selected variant is exposed in the namespaced
66
- `x_decision_toolkit.variant` extension, never in a standard field.
67
- `/v1/models`, `/v1/models/{id}`, `/v1/decision-models`, `/v1/info` and `/ready`
68
- expose the configured catalog variants, the default, cached variants, and the
69
- current selection. See [Variants and quantization](VARIANTS.md) for the catalog
70
- and download/select commands.
52
+ ## Variants as model aliases
53
+
54
+ Neither TypeSafe nor OpenAI has a `variant` request field. The toolkit therefore
55
+ publishes each quantization as a **model alias**:
56
+
57
+ ```json
58
+ {"model": "gliner2.5-multi@fp32", "state": "...", "questions": {...}}
59
+ ```
60
+
61
+ The `model` field is the standard field. `gliner2.5-multi` uses the server's
62
+ default (or the persisted `dtk models use` selection); `gliner2.5-multi@fp32`
63
+ selects that exact variant. Both aliases are listed by `/v1/models`, and the
64
+ registry loads them in separate slots, so two variants of one base model can be
65
+ used by the same server process.
66
+
67
+ `/v1/models/gliner2.5-multi@fp32`, `/v1/decision-models`, `/v1/info` and `/ready`
68
+ expose the alias, variant, default, cached state and current selection. The
69
+ response metadata under `x_decision_toolkit.variant` is an extension; the
70
+ request body remains standard. See [Variants and quantization](VARIANTS.md).
71
71
 
72
72
  ## System One (TypeSafe API schema 0.2.0)
73
73
 
@@ -45,8 +45,8 @@ the parity, hash and license gates for this catalog.
45
45
  The same catalog variant is used by the library, CLI and REST API. Selection
46
46
  precedence is:
47
47
 
48
- 1. an explicit per-call/per-request variant (`DecisionModel(..., variant=...)`,
49
- `--variant`, or `"variant"` in a REST body);
48
+ 1. an explicit per-call variant in the library or CLI
49
+ (`DecisionModel(..., variant=...)`, `--variant`);
50
50
  2. the CLI/server configuration (`dtk models use`, or `dtk serve --variant`);
51
51
  3. the catalog default marked with `*`.
52
52
 
@@ -54,17 +54,18 @@ precedence is:
54
54
  | --- | --- | --- |
55
55
  | Library | `DecisionModel("gliner2.5-multi", variant="fp32")` | persisted variant for the current model, else catalog default |
56
56
  | CLI | `--variant fp32` | variant selected by `dtk models use`, else catalog default |
57
- | REST (local extension) | `"variant": "fp32"` in the request body | server `--variant`, else persisted variant for the current model, else catalog default |
57
+ | REST | `"model": "gliner2.5-multi@fp32"` (model alias) | base model id; server `--variant`/persisted default |
58
58
 
59
59
  `DecisionModel.variant` and the CLI report the selected variant (`id@variant` in
60
60
  `dtk models current`). REST responses expose it under the namespaced
61
61
  `x_decision_toolkit.variant` extension; `/v1/decision-models` includes
62
62
  `current` and `current_variant`, plus all `variants` and `cached_variants`.
63
- `variant` is **not** part of TypeSafe or OpenAI; standard clients should omit it
64
- and rely on the server's `--variant`/persisted default. A server started with
63
+ `variant` is **not** part of TypeSafe or OpenAI, and standard REST clients must
64
+ not send it. They select only `model`; the local server chooses the quantization
65
+ from `--variant` or the persisted default. A server started with
65
66
  `--model`/`--variant` reports that default in `/v1/info` and `/ready`.
66
67
 
67
- The variant must have been downloaded before a library call or REST request
68
+ The variant must have been downloaded before a library call or REST alias
68
69
  loads it. An unknown variant returns a model-not-found error; a known but
69
70
  missing cache entry returns a model-not-downloaded error.
70
71
 
@@ -162,14 +163,18 @@ from decision_toolkit import DecisionModel
162
163
  model = DecisionModel("gliner2.5-multi", variant="fp32")
163
164
  ```
164
165
 
165
- From REST, send the variant in the request body:
166
+ For REST, select a quantization through the standard `model` field using the
167
+ alias `id@variant`:
166
168
 
167
169
  ```json
168
- {"model": "gliner2.5-multi", "variant": "fp32", "state": "...",
169
- "questions": {"cat": {"type": "choice", "instructions": "What kind?",
170
- "criteria": {"meeting": null, "other": null}}}}
170
+ {"model": "gliner2.5-multi@fp32", "state": "...", "questions": {}}
171
171
  ```
172
172
 
173
+ The base id (`gliner2.5-multi`) uses the server `--variant`/persisted default.
174
+ `/v1/models` lists base models and every alias, and the registry can hold two
175
+ aliases of the same base model in separate slots. Do not add a `variant` field
176
+ to the request body.
177
+
173
178
  The variant must already be downloaded. If you name an unknown variant, the
174
179
  error lists the catalogued choices, for example:
175
180
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "decision-toolkit"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Local-first toolkit for open-weights decision models: one ONNX engine, one adapter per model contract, typed choice/yes-no/score decisions with probabilities on CPU."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -7,7 +7,7 @@ questions with a calibrated probability for every option, on CPU.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- __version__ = "0.3.1"
10
+ __version__ = "0.4.0"
11
11
 
12
12
  from .adapters import register_adapter
13
13
  from .calibration import brier_score, expected_calibration_error, threshold_sweep
@@ -703,6 +703,23 @@ def get(model_id: str) -> CatalogModel | None:
703
703
  return CATALOG_BY_ID.get(model_id)
704
704
 
705
705
 
706
+ def parse_alias(model_id: str) -> tuple[str, str | None]:
707
+ """Split a REST model alias ``catalog-id@variant``.
708
+
709
+ Plain ids and non-catalog paths are returned unchanged. This is the
710
+ standard-compatible way to select a quantization: the OpenAI/TypeSafe
711
+ ``model`` field remains a single string; no non-standard ``variant`` body
712
+ field is needed.
713
+ """
714
+ if "@" not in model_id:
715
+ return model_id, None
716
+ base, variant_name = model_id.rsplit("@", 1)
717
+ model = get(base)
718
+ if model is not None and any(variant.name == variant_name for variant in model.variants):
719
+ return base, variant_name
720
+ return model_id, None
721
+
722
+
706
723
  def select_variant(model: CatalogModel, name: str | None = None) -> Variant:
707
724
  """Choose a variant by name, or the model's default."""
708
725
  if name is None:
@@ -732,5 +749,6 @@ __all__ = [
732
749
  "ModelInfo",
733
750
  "Variant",
734
751
  "get",
752
+ "parse_alias",
735
753
  "select_variant",
736
754
  ]
@@ -72,7 +72,7 @@ class OpenAIDecisionsRequest(BaseModel):
72
72
  min_length=1, max_length=MAX_QUESTIONS
73
73
  )
74
74
  model: str | None = None
75
- variant: str | None = None
75
+
76
76
  @model_validator(mode="after")
77
77
  def _validate_input(self) -> OpenAIDecisionsRequest:
78
78
  if self.input is None:
@@ -117,7 +117,6 @@ class SystemOneRequest(BaseModel):
117
117
  files: list[str] | None = None
118
118
  images: list[str] | None = None
119
119
  model: str | None = None
120
- variant: str | None = None
121
120
 
122
121
  @model_validator(mode="after")
123
122
  def _validate_state(self) -> SystemOneRequest:
@@ -165,6 +165,14 @@ class ModelRegistry:
165
165
  raise ModelNotFoundError(
166
166
  "no model configured; pass `model` or set one with `dtk models use`"
167
167
  )
168
+ base_identifier, alias_variant = catalog.parse_alias(identifier)
169
+ if alias_variant is not None:
170
+ identifier = base_identifier
171
+ if variant is not None and variant != alias_variant:
172
+ raise ModelNotFoundError(
173
+ f"model alias selects variant {alias_variant!r}, not {variant!r}"
174
+ )
175
+ variant = alias_variant
168
176
  effective_variant = variant
169
177
  if effective_variant is None and identifier == self.default_identifier:
170
178
  effective_variant = self.default_variant
@@ -378,6 +378,7 @@ def _rich_model_payload(
378
378
  *,
379
379
  current: str | None,
380
380
  current_variant: str | None = None,
381
+ selected_variant: catalog.Variant | None = None,
381
382
  ) -> dict[str, Any]:
382
383
  payload = model.info.to_dict()
383
384
  payload["cached_variants"] = [
@@ -385,12 +386,38 @@ def _rich_model_payload(
385
386
  ]
386
387
  payload["current"] = model.id == current
387
388
  payload["current_variant"] = current_variant if model.id == current else None
389
+ payload["base_model"] = model.id
390
+ if selected_variant is not None:
391
+ payload["selected_variant"] = selected_variant.name
392
+ payload["selected_quant"] = selected_variant.quant
388
393
  payload["object"] = "model"
389
394
  payload["owned_by"] = "decision-toolkit"
390
395
  payload["created"] = 0
391
396
  return payload
392
397
 
393
398
 
399
+ def _alias_payload(
400
+ model: catalog.CatalogModel,
401
+ variant: catalog.Variant,
402
+ cached: set[tuple[str, str]],
403
+ *,
404
+ current: str | None,
405
+ current_variant: str | None,
406
+ ) -> dict[str, Any]:
407
+ """OpenAI-shaped entry for the standard ``model`` alias ``id@variant``."""
408
+ payload = _rich_model_payload(
409
+ model,
410
+ cached,
411
+ current=current,
412
+ current_variant=current_variant,
413
+ selected_variant=variant,
414
+ )
415
+ payload["id"] = f"{model.id}@{variant.name}"
416
+ payload["name"] = f"{model.name} [{variant.name}]"
417
+ payload["alias"] = True
418
+ return payload
419
+
420
+
394
421
  def _cached_custom_payload(entry: Any) -> dict[str, Any]:
395
422
  return {
396
423
  "id": entry.identifier,
@@ -689,21 +716,35 @@ def create_app(
689
716
  @app.get("/v1/models", tags=["models"], dependencies=protected)
690
717
  def models() -> dict[str, Any]:
691
718
  _store, cached, current, current_variant = catalog_state()
692
- data = [
693
- {
694
- "id": model.id,
719
+ data = []
720
+ for catalog_model in catalog.CATALOG:
721
+ data.append({
722
+ "id": catalog_model.id,
695
723
  "object": "model",
696
724
  "created": 0,
697
725
  "owned_by": "decision-toolkit",
698
726
  "x_decision_toolkit": _rich_model_payload(
699
- model,
727
+ catalog_model,
700
728
  cached,
701
729
  current=current,
702
730
  current_variant=current_variant,
703
731
  ),
704
- }
705
- for model in catalog.CATALOG
706
- ]
732
+ })
733
+ for catalog_variant in catalog_model.variants:
734
+ rich = _alias_payload(
735
+ catalog_model,
736
+ catalog_variant,
737
+ cached,
738
+ current=current,
739
+ current_variant=current_variant,
740
+ )
741
+ data.append({
742
+ "id": rich["id"],
743
+ "object": "model",
744
+ "created": 0,
745
+ "owned_by": "decision-toolkit",
746
+ "x_decision_toolkit": rich,
747
+ })
707
748
  known = {model.id for model in catalog.CATALOG}
708
749
  for entry in _store.entries():
709
750
  if entry.identifier not in known:
@@ -714,16 +755,28 @@ def create_app(
714
755
  @app.get("/v1/models/{model_name:path}", tags=["models"], dependencies=protected)
715
756
  def model_detail(model_name: str) -> dict[str, Any]:
716
757
  _store, cached, current, current_variant = catalog_state()
717
- model = catalog.get(model_name)
758
+ base_name, alias_variant = catalog.parse_alias(model_name)
759
+ model = catalog.get(base_name)
718
760
  if model is not None:
719
- rich = _rich_model_payload(
720
- model,
721
- cached,
722
- current=current,
723
- current_variant=current_variant,
761
+ selected = catalog.select_variant(model, alias_variant) if alias_variant else None
762
+ rich = (
763
+ _alias_payload(
764
+ model,
765
+ selected,
766
+ cached,
767
+ current=current,
768
+ current_variant=current_variant,
769
+ )
770
+ if selected is not None
771
+ else _rich_model_payload(
772
+ model,
773
+ cached,
774
+ current=current,
775
+ current_variant=current_variant,
776
+ )
724
777
  )
725
778
  return {
726
- "id": model.id,
779
+ "id": rich["id"],
727
780
  "object": "model",
728
781
  "created": 0,
729
782
  "owned_by": "decision-toolkit",
@@ -782,7 +835,7 @@ def create_app(
782
835
  "in this catalog"
783
836
  )
784
837
  questions = systemone_questions(payload.questions)
785
- with registry.acquire(payload.model, payload.variant) as selected:
838
+ with registry.acquire(payload.model) as selected:
786
839
  decision = await run_decision(
787
840
  selected,
788
841
  payload.state,
@@ -826,7 +879,7 @@ def create_app(
826
879
  if len(set(names)) != len(names):
827
880
  raise InvalidRequestError("question names must be unique")
828
881
  state_text, core_questions, names = openai_to_systemone(payload.input, wire_questions)
829
- with registry.acquire(payload.model, payload.variant) as selected:
882
+ with registry.acquire(payload.model) as selected:
830
883
  decision = await run_decision(
831
884
  selected,
832
885
  state_text,
@@ -140,7 +140,7 @@ def test_rest_default_request_uses_persisted_variant(
140
140
  assert seen == [("gliner2.5-multi", "fp32")]
141
141
 
142
142
 
143
- def test_rest_explicit_variant_is_passed_to_the_model(monkeypatch, home):
143
+ def test_rest_model_alias_selects_a_variant(monkeypatch, home):
144
144
  from decision_toolkit import registry as registry_module
145
145
 
146
146
  seen: list[tuple[str | None, str | None]] = []
@@ -152,7 +152,8 @@ def test_rest_explicit_variant_is_passed_to_the_model(monkeypatch, home):
152
152
  monkeypatch.setattr(registry_module, "DecisionModel", fake_model)
153
153
  app = create_app(model_id="gliner2.5-multi", home=str(home))
154
154
  response = TestClient(app).post(
155
- "/v1/systemone", json=_systemone_body(variant="fp32")
155
+ "/v1/systemone",
156
+ json=_systemone_body(model="gliner2.5-multi@fp32"),
156
157
  )
157
158
  assert response.status_code == 200
158
159
  assert response.json()["x_decision_toolkit"]["variant"] == "fp32"
@@ -174,7 +175,7 @@ def test_rest_openai_request_passes_variant(monkeypatch, home):
174
175
  "/v1/decisions",
175
176
  json={
176
177
  "input": "Customer wants a refund.",
177
- "variant": "fp32",
178
+ "model": "gliner2.5-multi@fp32",
178
179
  "questions": [
179
180
  {
180
181
  "type": "choice",
@@ -215,12 +216,42 @@ def test_rest_catalog_exposes_current_variant(home, fake_snapshot):
215
216
  assert ready.json()["variant"] == "fp32"
216
217
 
217
218
 
218
- def test_fixed_model_rejects_a_different_variant(client):
219
+ def test_rest_loads_two_variants_of_one_model_in_separate_slots(
220
+ monkeypatch, home
221
+ ):
222
+ from decision_toolkit import registry as registry_module
223
+
224
+ seen: list[tuple[str | None, str | None]] = []
225
+
226
+ def fake_model(identifier=None, **kwargs):
227
+ seen.append((identifier, kwargs.get("variant")))
228
+ return _VariantModel(kwargs.get("variant") or "fp16")
229
+
230
+ monkeypatch.setattr(registry_module, "DecisionModel", fake_model)
231
+ app = create_app(model_id="gliner2.5-multi", home=str(home))
232
+ client = TestClient(app)
233
+ for alias in ("gliner2.5-multi@fp16", "gliner2.5-multi@fp32"):
234
+ response = client.post(
235
+ "/v1/systemone", json=_systemone_body(model=alias)
236
+ )
237
+ assert response.status_code == 200
238
+ assert seen == [
239
+ ("gliner2.5-multi", "fp16"),
240
+ ("gliner2.5-multi", "fp32"),
241
+ ]
242
+ loaded = app.state.registry.describe()["loaded"]
243
+ assert {item["key"] for item in loaded} == {
244
+ "gliner2.5-multi@fp16",
245
+ "gliner2.5-multi@fp32",
246
+ }
247
+
248
+
249
+ def test_rest_rejects_the_nonstandard_variant_body_field(client):
219
250
  response = client.post(
220
251
  "/v1/systemone", json=_systemone_body(variant="fp16")
221
252
  )
222
- assert response.status_code == 404
223
- assert "bound" in response.json()["error"]["message"]
253
+ assert response.status_code == 422
254
+ assert response.json()["detail"]
224
255
 
225
256
 
226
257
  # ----------------------------------------------------------------- system one
@@ -655,6 +686,19 @@ def test_models_detail_and_rich_catalog(client):
655
686
  assert entry["capabilities"]["max_options"] == 255
656
687
 
657
688
 
689
+ def test_models_endpoint_lists_variant_aliases(client):
690
+ payload = client.get("/v1/models").json()
691
+ by_id = {item["id"]: item for item in payload["data"]}
692
+ assert "gliner2.5-multi" in by_id
693
+ assert "gliner2.5-multi@fp16" in by_id
694
+ assert "gliner2.5-multi@fp32" in by_id
695
+ alias = by_id["gliner2.5-multi@fp32"]
696
+ assert alias["x_decision_toolkit"]["selected_variant"] == "fp32"
697
+ detail = client.get("/v1/models/gliner2.5-multi@fp32").json()
698
+ assert detail["id"] == "gliner2.5-multi@fp32"
699
+ assert detail["x_decision_toolkit"]["selected_variant"] == "fp32"
700
+
701
+
658
702
  def test_openai_sdk_can_list_models(client):
659
703
  openai = pytest.importorskip("openai")
660
704
 
@@ -723,9 +767,9 @@ def test_per_request_model_selection_uses_the_registry(monkeypatch, home):
723
767
  "/v1/systemone", json=_systemone_body(model="alpha")
724
768
  ).status_code == 200
725
769
  assert client.post(
726
- "/v1/systemone", json=_systemone_body(model="beta", variant="q8")
770
+ "/v1/systemone", json=_systemone_body(model="gliner2.5-multi@fp32")
727
771
  ).status_code == 200
728
772
  loaded = app.state.registry.describe()["loaded"]
729
773
  keys = {item["key"] for item in loaded}
730
774
  assert "alpha@" in keys
731
- assert "beta@q8" in keys
775
+ assert "gliner2.5-multi@fp32" in keys
@@ -349,7 +349,7 @@ wheels = [
349
349
 
350
350
  [[package]]
351
351
  name = "decision-toolkit"
352
- version = "0.3.1"
352
+ version = "0.4.0"
353
353
  source = { editable = "." }
354
354
  dependencies = [
355
355
  { name = "huggingface-hub" },