pydantic-open-inference 1.0.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.
- pydantic_open_inference-1.0.0/LICENSE +21 -0
- pydantic_open_inference-1.0.0/PKG-INFO +193 -0
- pydantic_open_inference-1.0.0/README.md +156 -0
- pydantic_open_inference-1.0.0/pyproject.toml +197 -0
- pydantic_open_inference-1.0.0/src/pydantic_open_inference/__init__.py +14 -0
- pydantic_open_inference-1.0.0/src/pydantic_open_inference/_client.py +72 -0
- pydantic_open_inference-1.0.0/src/pydantic_open_inference/_remote_model.py +256 -0
- pydantic_open_inference-1.0.0/src/pydantic_open_inference/_utils.py +168 -0
- pydantic_open_inference-1.0.0/src/pydantic_open_inference/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 1&1 Mail&Media
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydantic-open-inference
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Call an Open Inference server using pydantic models defining your model inputs and outputs.
|
|
5
|
+
Keywords: pydantic,open inference,kserve,triton,machine learning,ml,inference
|
|
6
|
+
Author: Anton Vikström
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Natural Language :: English
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Classifier: Intended Audience :: Science/Research
|
|
21
|
+
Classifier: Intended Audience :: Developers
|
|
22
|
+
Classifier: Intended Audience :: System Administrators
|
|
23
|
+
Classifier: Intended Audience :: Other Audience
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Topic :: Scientific/Engineering
|
|
27
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
28
|
+
Classifier: Framework :: Pydantic
|
|
29
|
+
Classifier: Framework :: Pydantic :: 2
|
|
30
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
31
|
+
Requires-Dist: httpx==0.28.*
|
|
32
|
+
Requires-Dist: more-itertools==10.* ; python_full_version < '3.13'
|
|
33
|
+
Requires-Dist: pydantic==2.*
|
|
34
|
+
Requires-Python: >=3.10
|
|
35
|
+
Project-URL: Repository, https://github.com/mam-dev/pydantic-open-inference
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# pydantic-open-inference
|
|
39
|
+
|
|
40
|
+
[](https://pypi.python.org/pypi/pydantic-open-inference)
|
|
42
|
+
[](https://github.com/mam-dev/pydantic-open-inference/blob/main/LICENSE)
|
|
44
|
+
[](https://pypi.python.org/pypi/pydantic-open-inference)
|
|
45
|
+
[](https://github.com/mam-dev/pydantic-open-inference/actions)
|
|
46
|
+
[](https://github.com/astral-sh/uv)
|
|
47
|
+
[](https://github.com/astral-sh/ruff)
|
|
48
|
+
[](https://www.mypy-lang.org)
|
|
49
|
+
|
|
50
|
+
## What is it?
|
|
51
|
+
Provides a client-side python interface for calling [open inference servers](https://kserve.github.io/website/docs/concepts/architecture/data-plane/v2-protocol).
|
|
52
|
+
Define [pydantic](https://docs.pydantic.dev/latest/) models for the model inputs and outputs, and it gets automatically
|
|
53
|
+
converted to and from the open inference protocol, removing the need for boilerplate
|
|
54
|
+
coding handling raw tensors and their shapes.
|
|
55
|
+
|
|
56
|
+
## Features
|
|
57
|
+
* ✅ Simple, type-safe API toward an open inference server using user-defined pydantic models
|
|
58
|
+
* ✅ Automatic shape inference from pydantic models
|
|
59
|
+
* ✅ Automatic datatype mapping (`int`→INT64, `float`→FP32, `str`→BYTES)
|
|
60
|
+
* ✅ Datatype override for fine-grained control of model inputs
|
|
61
|
+
* ✅ Automatically request only a subset of model outputs by omitting fields in your pydantic model
|
|
62
|
+
* ✅ Take advantage of the full power of pydantic to validate and type-coerce your model calls
|
|
63
|
+
* ✅ Multi-input/output models - each input/output maps to a pydantic field
|
|
64
|
+
* ✅ Custom timeout configuration - configurable per model
|
|
65
|
+
* ✅ Support calling inference for versioned and unversioned models
|
|
66
|
+
* ✅ Error handling with custom exceptions - handle, e.g., specific error codes from the inference server differently
|
|
67
|
+
* ✅ Fully typed - use, e.g., [mypy](https://mypy-lang.org/) and its [pydantic plugin](https://docs.pydantic.dev/latest/integrations/mypy/) to type check your code
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
Installation using [pip](https://github.com/pypa/pip):
|
|
71
|
+
```bash
|
|
72
|
+
pip install pydantic-open-inference
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Adding it to your project dependencies using [uv](https://docs.astral.sh/uv/):
|
|
76
|
+
```bash
|
|
77
|
+
uv add pydantic-open-inference
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## How to use it
|
|
81
|
+
Assume we have an inference server serving an Open Inference V2 REST API at `http://localhost:8080`
|
|
82
|
+
and that the server contains a model named `example_model`, which has the following model **inputs**
|
|
83
|
+
|
|
84
|
+
| name | shape | datatype | description | example data |
|
|
85
|
+
|------|-------|----------|--------------------------|--------------------|
|
|
86
|
+
| text | [1] | BYTES | A single string of text. | `["This is a text"]` |
|
|
87
|
+
|
|
88
|
+
and the following model **outputs**:
|
|
89
|
+
|
|
90
|
+
| name | shape | datatype | description | example data |
|
|
91
|
+
|----------|---------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------|
|
|
92
|
+
| entities | [-1, 4] | BYTES | <div style="width:200px">A list of entities extracted from the text. Each entity is made up of (in order) a label (either "order-id" or "invoice-id"), a confidence score, a start index for the entity in the text, and an end index.</div> | `[["order-id", "0.9", "23", "30"],["invoice-id","0.8","34","46"]]` |
|
|
93
|
+
|
|
94
|
+
We then define a pydantic model for the inputs, inheriting from `InputsBaseModel`.
|
|
95
|
+
Each model input name needs to correspond to a field in the pydantic model, and the type of the
|
|
96
|
+
field needs to make sense given the shape and datatype of the input. In this case, there
|
|
97
|
+
is just a single input named "text" that is supposed to hold a single text, so we simply define
|
|
98
|
+
```python
|
|
99
|
+
from pydantic_open_inference import InputsBaseModel
|
|
100
|
+
|
|
101
|
+
class ExampleModelInputs(InputsBaseModel):
|
|
102
|
+
text: str
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
In the same way, we define a pydantic model for the outputs, inheriting from `OutputsBaseModel`.
|
|
107
|
+
There is one model outputs named "entities", so that will be the only field we define.
|
|
108
|
+
There can be any number of entities (corresponding to the -1 in the shape), so we define it to
|
|
109
|
+
be a `list`. A list of what? Here we have some choices. We could simply define `entities: list[list[str]]`
|
|
110
|
+
and get each entity as a list of strings, e.g., `["order-id", "0.98", "12", "24"]`, but this is not
|
|
111
|
+
very useful to us, so we instead define, e.g., a _named tuple_ with ordered, named, and typed fields:
|
|
112
|
+
```python
|
|
113
|
+
from typing import NamedTuple
|
|
114
|
+
from enum import StrEnum
|
|
115
|
+
from pydantic_open_inference import OutputsBaseModel
|
|
116
|
+
|
|
117
|
+
class Label(StrEnum):
|
|
118
|
+
ORDER_ID = "order-id"
|
|
119
|
+
INVOICE_ID = "invoice-id"
|
|
120
|
+
|
|
121
|
+
class Entity(NamedTuple):
|
|
122
|
+
label: Label
|
|
123
|
+
score: float
|
|
124
|
+
start: int
|
|
125
|
+
end: int
|
|
126
|
+
|
|
127
|
+
class ExampleModelOutputs(OutputsBaseModel):
|
|
128
|
+
entities: list[Entity]
|
|
129
|
+
```
|
|
130
|
+
Note that we could also have used `typing.Literal` instead of `enum.StrEnum`, and even added further validation,
|
|
131
|
+
e.g., that the score is between 0.0 and 1.0, and that the start index is not negative etc. Pydantic offers many
|
|
132
|
+
possibilities, but for now, let us stick with this.
|
|
133
|
+
|
|
134
|
+
We now create a `RemoteModel` instance. The `RemoteModel` class represents a model in the inference server,
|
|
135
|
+
in this case our "example_model". Instantiation requires the URL of the inference server, the name of the
|
|
136
|
+
model, and the pydantic models we defined for the input and output:
|
|
137
|
+
```python
|
|
138
|
+
from pydantic_open_inference import RemoteModel
|
|
139
|
+
|
|
140
|
+
INFERENCE_SERVER_URL = "http://localhost:8080"
|
|
141
|
+
|
|
142
|
+
example_model = RemoteModel(
|
|
143
|
+
server_url=INFERENCE_SERVER_URL,
|
|
144
|
+
model_name="example_model",
|
|
145
|
+
inputs_model=ExampleModelInputs,
|
|
146
|
+
outputs_model=ExampleModelOutputs,
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
In order to call the inference server, we simply call the `RemoteModel.infer` method, giving
|
|
151
|
+
it an instance of `ExampleModelInputs`, and get back an instance of `ExampleModelOutputs`:
|
|
152
|
+
```python
|
|
153
|
+
example_input = ExampleModelInputs(text="Your order id is 123456.")
|
|
154
|
+
example_output = example_model.infer(example_input)
|
|
155
|
+
# ExampleModelOutputs(
|
|
156
|
+
# entities=[
|
|
157
|
+
# Entity(label=Label.ORDER_ID, score=0.95, start=17, end=23),
|
|
158
|
+
# ]
|
|
159
|
+
# )
|
|
160
|
+
```
|
|
161
|
+
We have thus not only made a perfectly type-safe and validated call to the inference server,
|
|
162
|
+
we have also gotten the response back in a useful format! We never had to bother with
|
|
163
|
+
handling the shape of the data, parsing it in row-major order etc, as all of that is handled under the hood!
|
|
164
|
+
|
|
165
|
+
The only time you need to think about "shape" is when defining the fields of the pydantic models.
|
|
166
|
+
Use types like, e.g., `list`, `tuple`, and `typing.NamedTuple`, to type the pydantic-model fields
|
|
167
|
+
so that they map sensibly to their corresponding model input/output. Each input/output name must
|
|
168
|
+
match to a pydantic field and the shape must correspond to the structure of the field, e.g.,
|
|
169
|
+
`list[tuple[int, int]]` for shape [-1, 2], or `tuple[tuple[float, float], tuple[float, float]]`
|
|
170
|
+
for shape [2, 2], etc.
|
|
171
|
+
|
|
172
|
+
The "datatype" of the model _inputs_ is automatically set based on the innermost type of the
|
|
173
|
+
corresponding field in the `InputsBaseModel` subclass, according to:
|
|
174
|
+
|
|
175
|
+
| python type | datatype |
|
|
176
|
+
|-------------|----------|
|
|
177
|
+
| bool | BOOL |
|
|
178
|
+
| int | INT64 |
|
|
179
|
+
| float | FP32 |
|
|
180
|
+
| str | BYTES |
|
|
181
|
+
|
|
182
|
+
However, you can always _override_ the datatype of the model inputs to whatever you want using
|
|
183
|
+
`pydantic_open_inference.DatatypeOverride`; see its docstring for more information.
|
|
184
|
+
The datatypes of the model _outputs_ are disregarded; all that matters is
|
|
185
|
+
that the data we got back from the model can be mapped to the defined `OutputsBaseModel` subclass.
|
|
186
|
+
|
|
187
|
+
When an inference call to a model is made, it automatically requests only the specific outputs
|
|
188
|
+
for which you have defined fields in your `OutputsBaseModel` subclass. So if you are not
|
|
189
|
+
interested in an output, simply do not define a pydantic field for it.
|
|
190
|
+
|
|
191
|
+
## Examples
|
|
192
|
+
|
|
193
|
+
See the [examples directory](examples) for more examples.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# pydantic-open-inference
|
|
2
|
+
|
|
3
|
+
[](https://pypi.python.org/pypi/pydantic-open-inference)
|
|
5
|
+
[](https://github.com/mam-dev/pydantic-open-inference/blob/main/LICENSE)
|
|
7
|
+
[](https://pypi.python.org/pypi/pydantic-open-inference)
|
|
8
|
+
[](https://github.com/mam-dev/pydantic-open-inference/actions)
|
|
9
|
+
[](https://github.com/astral-sh/uv)
|
|
10
|
+
[](https://github.com/astral-sh/ruff)
|
|
11
|
+
[](https://www.mypy-lang.org)
|
|
12
|
+
|
|
13
|
+
## What is it?
|
|
14
|
+
Provides a client-side python interface for calling [open inference servers](https://kserve.github.io/website/docs/concepts/architecture/data-plane/v2-protocol).
|
|
15
|
+
Define [pydantic](https://docs.pydantic.dev/latest/) models for the model inputs and outputs, and it gets automatically
|
|
16
|
+
converted to and from the open inference protocol, removing the need for boilerplate
|
|
17
|
+
coding handling raw tensors and their shapes.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
* ✅ Simple, type-safe API toward an open inference server using user-defined pydantic models
|
|
21
|
+
* ✅ Automatic shape inference from pydantic models
|
|
22
|
+
* ✅ Automatic datatype mapping (`int`→INT64, `float`→FP32, `str`→BYTES)
|
|
23
|
+
* ✅ Datatype override for fine-grained control of model inputs
|
|
24
|
+
* ✅ Automatically request only a subset of model outputs by omitting fields in your pydantic model
|
|
25
|
+
* ✅ Take advantage of the full power of pydantic to validate and type-coerce your model calls
|
|
26
|
+
* ✅ Multi-input/output models - each input/output maps to a pydantic field
|
|
27
|
+
* ✅ Custom timeout configuration - configurable per model
|
|
28
|
+
* ✅ Support calling inference for versioned and unversioned models
|
|
29
|
+
* ✅ Error handling with custom exceptions - handle, e.g., specific error codes from the inference server differently
|
|
30
|
+
* ✅ Fully typed - use, e.g., [mypy](https://mypy-lang.org/) and its [pydantic plugin](https://docs.pydantic.dev/latest/integrations/mypy/) to type check your code
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
Installation using [pip](https://github.com/pypa/pip):
|
|
34
|
+
```bash
|
|
35
|
+
pip install pydantic-open-inference
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Adding it to your project dependencies using [uv](https://docs.astral.sh/uv/):
|
|
39
|
+
```bash
|
|
40
|
+
uv add pydantic-open-inference
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## How to use it
|
|
44
|
+
Assume we have an inference server serving an Open Inference V2 REST API at `http://localhost:8080`
|
|
45
|
+
and that the server contains a model named `example_model`, which has the following model **inputs**
|
|
46
|
+
|
|
47
|
+
| name | shape | datatype | description | example data |
|
|
48
|
+
|------|-------|----------|--------------------------|--------------------|
|
|
49
|
+
| text | [1] | BYTES | A single string of text. | `["This is a text"]` |
|
|
50
|
+
|
|
51
|
+
and the following model **outputs**:
|
|
52
|
+
|
|
53
|
+
| name | shape | datatype | description | example data |
|
|
54
|
+
|----------|---------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------|
|
|
55
|
+
| entities | [-1, 4] | BYTES | <div style="width:200px">A list of entities extracted from the text. Each entity is made up of (in order) a label (either "order-id" or "invoice-id"), a confidence score, a start index for the entity in the text, and an end index.</div> | `[["order-id", "0.9", "23", "30"],["invoice-id","0.8","34","46"]]` |
|
|
56
|
+
|
|
57
|
+
We then define a pydantic model for the inputs, inheriting from `InputsBaseModel`.
|
|
58
|
+
Each model input name needs to correspond to a field in the pydantic model, and the type of the
|
|
59
|
+
field needs to make sense given the shape and datatype of the input. In this case, there
|
|
60
|
+
is just a single input named "text" that is supposed to hold a single text, so we simply define
|
|
61
|
+
```python
|
|
62
|
+
from pydantic_open_inference import InputsBaseModel
|
|
63
|
+
|
|
64
|
+
class ExampleModelInputs(InputsBaseModel):
|
|
65
|
+
text: str
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
In the same way, we define a pydantic model for the outputs, inheriting from `OutputsBaseModel`.
|
|
70
|
+
There is one model outputs named "entities", so that will be the only field we define.
|
|
71
|
+
There can be any number of entities (corresponding to the -1 in the shape), so we define it to
|
|
72
|
+
be a `list`. A list of what? Here we have some choices. We could simply define `entities: list[list[str]]`
|
|
73
|
+
and get each entity as a list of strings, e.g., `["order-id", "0.98", "12", "24"]`, but this is not
|
|
74
|
+
very useful to us, so we instead define, e.g., a _named tuple_ with ordered, named, and typed fields:
|
|
75
|
+
```python
|
|
76
|
+
from typing import NamedTuple
|
|
77
|
+
from enum import StrEnum
|
|
78
|
+
from pydantic_open_inference import OutputsBaseModel
|
|
79
|
+
|
|
80
|
+
class Label(StrEnum):
|
|
81
|
+
ORDER_ID = "order-id"
|
|
82
|
+
INVOICE_ID = "invoice-id"
|
|
83
|
+
|
|
84
|
+
class Entity(NamedTuple):
|
|
85
|
+
label: Label
|
|
86
|
+
score: float
|
|
87
|
+
start: int
|
|
88
|
+
end: int
|
|
89
|
+
|
|
90
|
+
class ExampleModelOutputs(OutputsBaseModel):
|
|
91
|
+
entities: list[Entity]
|
|
92
|
+
```
|
|
93
|
+
Note that we could also have used `typing.Literal` instead of `enum.StrEnum`, and even added further validation,
|
|
94
|
+
e.g., that the score is between 0.0 and 1.0, and that the start index is not negative etc. Pydantic offers many
|
|
95
|
+
possibilities, but for now, let us stick with this.
|
|
96
|
+
|
|
97
|
+
We now create a `RemoteModel` instance. The `RemoteModel` class represents a model in the inference server,
|
|
98
|
+
in this case our "example_model". Instantiation requires the URL of the inference server, the name of the
|
|
99
|
+
model, and the pydantic models we defined for the input and output:
|
|
100
|
+
```python
|
|
101
|
+
from pydantic_open_inference import RemoteModel
|
|
102
|
+
|
|
103
|
+
INFERENCE_SERVER_URL = "http://localhost:8080"
|
|
104
|
+
|
|
105
|
+
example_model = RemoteModel(
|
|
106
|
+
server_url=INFERENCE_SERVER_URL,
|
|
107
|
+
model_name="example_model",
|
|
108
|
+
inputs_model=ExampleModelInputs,
|
|
109
|
+
outputs_model=ExampleModelOutputs,
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
In order to call the inference server, we simply call the `RemoteModel.infer` method, giving
|
|
114
|
+
it an instance of `ExampleModelInputs`, and get back an instance of `ExampleModelOutputs`:
|
|
115
|
+
```python
|
|
116
|
+
example_input = ExampleModelInputs(text="Your order id is 123456.")
|
|
117
|
+
example_output = example_model.infer(example_input)
|
|
118
|
+
# ExampleModelOutputs(
|
|
119
|
+
# entities=[
|
|
120
|
+
# Entity(label=Label.ORDER_ID, score=0.95, start=17, end=23),
|
|
121
|
+
# ]
|
|
122
|
+
# )
|
|
123
|
+
```
|
|
124
|
+
We have thus not only made a perfectly type-safe and validated call to the inference server,
|
|
125
|
+
we have also gotten the response back in a useful format! We never had to bother with
|
|
126
|
+
handling the shape of the data, parsing it in row-major order etc, as all of that is handled under the hood!
|
|
127
|
+
|
|
128
|
+
The only time you need to think about "shape" is when defining the fields of the pydantic models.
|
|
129
|
+
Use types like, e.g., `list`, `tuple`, and `typing.NamedTuple`, to type the pydantic-model fields
|
|
130
|
+
so that they map sensibly to their corresponding model input/output. Each input/output name must
|
|
131
|
+
match to a pydantic field and the shape must correspond to the structure of the field, e.g.,
|
|
132
|
+
`list[tuple[int, int]]` for shape [-1, 2], or `tuple[tuple[float, float], tuple[float, float]]`
|
|
133
|
+
for shape [2, 2], etc.
|
|
134
|
+
|
|
135
|
+
The "datatype" of the model _inputs_ is automatically set based on the innermost type of the
|
|
136
|
+
corresponding field in the `InputsBaseModel` subclass, according to:
|
|
137
|
+
|
|
138
|
+
| python type | datatype |
|
|
139
|
+
|-------------|----------|
|
|
140
|
+
| bool | BOOL |
|
|
141
|
+
| int | INT64 |
|
|
142
|
+
| float | FP32 |
|
|
143
|
+
| str | BYTES |
|
|
144
|
+
|
|
145
|
+
However, you can always _override_ the datatype of the model inputs to whatever you want using
|
|
146
|
+
`pydantic_open_inference.DatatypeOverride`; see its docstring for more information.
|
|
147
|
+
The datatypes of the model _outputs_ are disregarded; all that matters is
|
|
148
|
+
that the data we got back from the model can be mapped to the defined `OutputsBaseModel` subclass.
|
|
149
|
+
|
|
150
|
+
When an inference call to a model is made, it automatically requests only the specific outputs
|
|
151
|
+
for which you have defined fields in your `OutputsBaseModel` subclass. So if you are not
|
|
152
|
+
interested in an output, simply do not define a pydantic field for it.
|
|
153
|
+
|
|
154
|
+
## Examples
|
|
155
|
+
|
|
156
|
+
See the [examples directory](examples) for more examples.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pydantic-open-inference"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Call an Open Inference server using pydantic models defining your model inputs and outputs."
|
|
5
|
+
authors = [{ name = "Anton Vikström" }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
keywords = [
|
|
11
|
+
"pydantic",
|
|
12
|
+
"open inference",
|
|
13
|
+
"kserve",
|
|
14
|
+
"triton",
|
|
15
|
+
"machine learning",
|
|
16
|
+
"ml",
|
|
17
|
+
"inference",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Natural Language :: English",
|
|
21
|
+
"Programming Language :: Python",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
"Intended Audience :: Science/Research",
|
|
32
|
+
"Intended Audience :: Developers",
|
|
33
|
+
"Intended Audience :: System Administrators",
|
|
34
|
+
"Intended Audience :: Other Audience",
|
|
35
|
+
"Topic :: Software Development :: Libraries",
|
|
36
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
37
|
+
"Topic :: Scientific/Engineering",
|
|
38
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
39
|
+
"Framework :: Pydantic",
|
|
40
|
+
"Framework :: Pydantic :: 2",
|
|
41
|
+
"Development Status :: 5 - Production/Stable",
|
|
42
|
+
]
|
|
43
|
+
dependencies = [
|
|
44
|
+
"httpx==0.28.*",
|
|
45
|
+
"more-itertools==10.* ; python_full_version < '3.13'",
|
|
46
|
+
"pydantic==2.*",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
dev = [
|
|
51
|
+
"coverage>=7.0.0",
|
|
52
|
+
"mypy>=1.18.2",
|
|
53
|
+
"pytest>=9.0.0",
|
|
54
|
+
"pytest-cov>=7.0.0",
|
|
55
|
+
"pytest-integration>=0.2.3",
|
|
56
|
+
"pytest-random-order>=1.2.0",
|
|
57
|
+
"ruff>=0.14.4",
|
|
58
|
+
"typing-extensions>=4.15.0 ; python_full_version < '3.12'",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[build-system]
|
|
62
|
+
requires = ["uv_build>=0.9.8,<0.10.0"]
|
|
63
|
+
build-backend = "uv_build"
|
|
64
|
+
|
|
65
|
+
[project.urls]
|
|
66
|
+
Repository = "https://github.com/mam-dev/pydantic-open-inference"
|
|
67
|
+
|
|
68
|
+
[tool.uv]
|
|
69
|
+
cache-keys = [{ file = "pyproject.toml" }, { git = true }]
|
|
70
|
+
|
|
71
|
+
[[tool.uv.index]]
|
|
72
|
+
url = "https://pypi.python.org/simple"
|
|
73
|
+
default = true
|
|
74
|
+
|
|
75
|
+
[tool.coverage.run]
|
|
76
|
+
branch = true
|
|
77
|
+
source = ["src", "examples"]
|
|
78
|
+
|
|
79
|
+
[tool.coverage.report]
|
|
80
|
+
show_missing = true
|
|
81
|
+
fail_under = 100
|
|
82
|
+
|
|
83
|
+
[tool.pytest.ini_options]
|
|
84
|
+
addopts = ["--cov", "--random-order"]
|
|
85
|
+
testpaths = ["tests"]
|
|
86
|
+
|
|
87
|
+
[tool.mypy]
|
|
88
|
+
files = ["src", "tests", "examples"]
|
|
89
|
+
plugins = ["pydantic.mypy"]
|
|
90
|
+
pretty = true
|
|
91
|
+
warn_no_return = true
|
|
92
|
+
warn_return_any = true
|
|
93
|
+
warn_unused_configs = true
|
|
94
|
+
warn_unused_ignores = true
|
|
95
|
+
warn_redundant_casts = true
|
|
96
|
+
warn_unreachable = true
|
|
97
|
+
check_untyped_defs = true
|
|
98
|
+
disallow_any_generics = true
|
|
99
|
+
disallow_subclassing_any = true
|
|
100
|
+
disallow_untyped_calls = true
|
|
101
|
+
disallow_untyped_defs = true
|
|
102
|
+
disallow_incomplete_defs = true
|
|
103
|
+
disallow_untyped_decorators = true
|
|
104
|
+
no_implicit_optional = true
|
|
105
|
+
no_implicit_reexport = true
|
|
106
|
+
strict_equality = true
|
|
107
|
+
extra_checks = true
|
|
108
|
+
enable_error_code = """
|
|
109
|
+
explicit-override,\
|
|
110
|
+
deprecated,\
|
|
111
|
+
truthy-iterable,\
|
|
112
|
+
ignore-without-code,\
|
|
113
|
+
possibly-undefined,\
|
|
114
|
+
redundant-expr,\
|
|
115
|
+
truthy-bool"""
|
|
116
|
+
|
|
117
|
+
[tool.pydantic-mypy]
|
|
118
|
+
init_forbid_extra = true
|
|
119
|
+
init_typed = true
|
|
120
|
+
warn_required_dynamic_aliases = true
|
|
121
|
+
warn_untyped_fields = true
|
|
122
|
+
|
|
123
|
+
[tool.ruff]
|
|
124
|
+
line-length = 120
|
|
125
|
+
extend-include = ["tests/**/.py", "examples/**/*.py"]
|
|
126
|
+
|
|
127
|
+
[tool.ruff.lint]
|
|
128
|
+
select = [
|
|
129
|
+
"E", # pycodestyle errors
|
|
130
|
+
"W", # pycodestyle warnings
|
|
131
|
+
"F", # pyflakes
|
|
132
|
+
"UP", # pyupgrade
|
|
133
|
+
"S", # flake8-bandit
|
|
134
|
+
"D", # pydocstyle
|
|
135
|
+
"PT", # flake8-pytest-style
|
|
136
|
+
"I", # isort
|
|
137
|
+
"RUF", # Ruff-specific rules
|
|
138
|
+
"PTH", # flake8-use-pathlib
|
|
139
|
+
"ERA", # eradicate
|
|
140
|
+
"PL", # pylint
|
|
141
|
+
"FBT", # flake8-boolean-trap
|
|
142
|
+
"B", # flake8-bugbear
|
|
143
|
+
"A", # flake8-builtins
|
|
144
|
+
"ISC", # flake8-implicit-str-concat
|
|
145
|
+
"INP", # flake8-no-pep420
|
|
146
|
+
"SLF", # flake8-self
|
|
147
|
+
"SIM", # flake8-simplify
|
|
148
|
+
"TID", # flake8-tidy-imports
|
|
149
|
+
"ARG", # flake8-unused-arguments
|
|
150
|
+
"TRY", # tryceratops
|
|
151
|
+
"FLY", # flynt
|
|
152
|
+
"RSE", # flake8-raise
|
|
153
|
+
"RET", # flake8-return
|
|
154
|
+
"FIX", # flake8-fixme
|
|
155
|
+
"Q", # flake8-quotes
|
|
156
|
+
"C4", # flake8-comprehensions
|
|
157
|
+
"DTZ", # flake8-datetimez
|
|
158
|
+
"T10", # flake8-debugger
|
|
159
|
+
"T20", # flake8-print
|
|
160
|
+
"TCH", # flake8-type-checking
|
|
161
|
+
"ASYNC", # flake8-async
|
|
162
|
+
"BLE", # flake8-blind-except
|
|
163
|
+
"LOG", # flake8-logging
|
|
164
|
+
"SLOT", # flake8-slots
|
|
165
|
+
"NPY", # Numpy-specific rules
|
|
166
|
+
"PD", # pandas-vet
|
|
167
|
+
"PERF", # Perflint
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
[tool.ruff.lint.per-file-ignores]
|
|
171
|
+
"tests/**/*.py" = [
|
|
172
|
+
"D101", # undocumented public class
|
|
173
|
+
"D103", # undocumented public function
|
|
174
|
+
"FBT001", # boolean positional arg
|
|
175
|
+
"SLF001", # private member access
|
|
176
|
+
"PLR2004", # magic-value compare
|
|
177
|
+
"PLR0913", # too many args
|
|
178
|
+
"ARG001", # unused arg
|
|
179
|
+
"S101", # use of 'assert'
|
|
180
|
+
]
|
|
181
|
+
"examples/**/*.py" = [
|
|
182
|
+
"T201", # print statement
|
|
183
|
+
"INP001", # implicit namespace
|
|
184
|
+
"ERA001", # commented-out code
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
[tool.ruff.lint.pydocstyle]
|
|
188
|
+
convention = "google"
|
|
189
|
+
|
|
190
|
+
[tool.ruff.lint.flake8-pytest-style]
|
|
191
|
+
fixture-parentheses = false
|
|
192
|
+
mark-parentheses = false
|
|
193
|
+
parametrize-names-type = "csv"
|
|
194
|
+
parametrize-values-type = "list"
|
|
195
|
+
|
|
196
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
197
|
+
ban-relative-imports = "parents"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Package for wrapping calls to an Open Inference server REST API."""
|
|
2
|
+
|
|
3
|
+
from ._client import BadStatusCodeFromServerError
|
|
4
|
+
from ._remote_model import InputsBaseModel, OutputsBaseModel, RemoteModel
|
|
5
|
+
from ._utils import DatatypeOverride, PydanticOpenInferenceError
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"BadStatusCodeFromServerError",
|
|
9
|
+
"DatatypeOverride",
|
|
10
|
+
"InputsBaseModel",
|
|
11
|
+
"OutputsBaseModel",
|
|
12
|
+
"PydanticOpenInferenceError",
|
|
13
|
+
"RemoteModel",
|
|
14
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import json
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._utils import (
|
|
10
|
+
OpenInferenceAPIInput,
|
|
11
|
+
OpenInferenceAPIOutput,
|
|
12
|
+
OpenInferenceAPIRequestedOutput,
|
|
13
|
+
PydanticOpenInferenceError,
|
|
14
|
+
Singleton,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenInferenceHTTPClientAPIError(PydanticOpenInferenceError):
|
|
19
|
+
"""Exceptions from the OpenInferenceHTTPClientAPI."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BadStatusCodeFromServerError(OpenInferenceHTTPClientAPIError):
|
|
23
|
+
"""A bad status code was returned from the open inference server."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status_code: int) -> None:
|
|
26
|
+
self._status_code = status_code
|
|
27
|
+
message = f"Inference server responded with HTTP {status_code}"
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def status_code(self) -> int:
|
|
32
|
+
return self._status_code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OpenInferenceHTTPClientAPI(metaclass=Singleton):
|
|
36
|
+
def __init__(self, base_url: str) -> None:
|
|
37
|
+
if not base_url.endswith("/"):
|
|
38
|
+
base_url += "/"
|
|
39
|
+
self._client = httpx.Client(
|
|
40
|
+
base_url=base_url,
|
|
41
|
+
headers={"Content-Type": "application/json"},
|
|
42
|
+
)
|
|
43
|
+
atexit.register(self._client.close)
|
|
44
|
+
|
|
45
|
+
def infer(
|
|
46
|
+
self,
|
|
47
|
+
model_name: str,
|
|
48
|
+
inputs: list[OpenInferenceAPIInput],
|
|
49
|
+
outputs: list[OpenInferenceAPIRequestedOutput] | None = None,
|
|
50
|
+
model_version: str | None = None,
|
|
51
|
+
timeout_seconds: float | None = None,
|
|
52
|
+
) -> list[OpenInferenceAPIOutput]:
|
|
53
|
+
try:
|
|
54
|
+
payload: dict[str, list[OpenInferenceAPIInput] | list[OpenInferenceAPIRequestedOutput]] = {"inputs": inputs}
|
|
55
|
+
if outputs is not None:
|
|
56
|
+
payload["outputs"] = outputs
|
|
57
|
+
if model_version is None:
|
|
58
|
+
url = f"v2/models/{model_name}/infer"
|
|
59
|
+
else:
|
|
60
|
+
url = f"v2/models/{model_name}/versions/{model_version}/infer"
|
|
61
|
+
response = self._client.post(
|
|
62
|
+
url,
|
|
63
|
+
json=payload,
|
|
64
|
+
timeout=timeout_seconds,
|
|
65
|
+
).raise_for_status()
|
|
66
|
+
return cast("list[OpenInferenceAPIOutput]", response.json().get("outputs", []))
|
|
67
|
+
except httpx.HTTPStatusError as error:
|
|
68
|
+
raise BadStatusCodeFromServerError(
|
|
69
|
+
status_code=error.response.status_code,
|
|
70
|
+
) from error
|
|
71
|
+
except (httpx.HTTPError, json.JSONDecodeError) as error:
|
|
72
|
+
raise OpenInferenceHTTPClientAPIError(error) from error
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
if sys.version_info < (3, 11):
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
else:
|
|
11
|
+
from typing import Self
|
|
12
|
+
|
|
13
|
+
import pydantic
|
|
14
|
+
|
|
15
|
+
from ._client import OpenInferenceHTTPClientAPI
|
|
16
|
+
from ._utils import (
|
|
17
|
+
OpenInferenceAPIInput,
|
|
18
|
+
OpenInferenceAPIOutput,
|
|
19
|
+
OpenInferenceAPIRequestedOutput,
|
|
20
|
+
get_data,
|
|
21
|
+
get_datatype,
|
|
22
|
+
get_shape,
|
|
23
|
+
is_listlike,
|
|
24
|
+
unflatten_data,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InputsBaseModel(pydantic.BaseModel):
|
|
29
|
+
"""Base class for model inputs.
|
|
30
|
+
|
|
31
|
+
Subclass this class to define the model inputs, e.g.,
|
|
32
|
+
|
|
33
|
+
class MyModelInput(InputsBaseModel):
|
|
34
|
+
values: list[int]
|
|
35
|
+
|
|
36
|
+
The field names MUST correspond to the names of the model inputs.
|
|
37
|
+
|
|
38
|
+
The structure of a field must be mappable to the shape of the
|
|
39
|
+
corresponding input, e.g.,
|
|
40
|
+
- "list[tuple[int, int]]" for a shape of [-1, 2],
|
|
41
|
+
- "tuple[float, float]" for a shape of [2], or
|
|
42
|
+
- "list[tuple[tuple[int, int], tuple[int, int]]]" for a shape of [-1, 2, 2].
|
|
43
|
+
The actual shape in the input that is sent will have the actual size, not -1.
|
|
44
|
+
|
|
45
|
+
NOTE: You can define an input field that is neither a list nor a tuple, just
|
|
46
|
+
a simple datatype like int, float, str; this will correspond to a shape of [1].
|
|
47
|
+
|
|
48
|
+
You can use typing.Namedtuple instead of a tuple to get named fields.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
class Point(typing.Namedtuple):
|
|
52
|
+
x: float
|
|
53
|
+
y: float
|
|
54
|
+
|
|
55
|
+
class ExampleInput(InputsBaseModel):
|
|
56
|
+
points: list[Point] # name: "points", shape: [-1, 2]
|
|
57
|
+
matrix: tuple[tuple[int, int, int], tuple[int, int, int]] # name: "matrix", shape: [2, 3]
|
|
58
|
+
text: str # name: "text", shape: [1]
|
|
59
|
+
|
|
60
|
+
The datatype of an input is automatically set depending on the field type, e.g.,
|
|
61
|
+
for a field that is "list[int]" (or "list[tuple[int, int]]" etc) the datatype
|
|
62
|
+
will be "INT64". The actual mapping is
|
|
63
|
+
|
|
64
|
+
bool: "BOOL",
|
|
65
|
+
int: "INT64",
|
|
66
|
+
float: "FP32",
|
|
67
|
+
str: "BYTES",
|
|
68
|
+
|
|
69
|
+
You can override these defaults using DatatypeOverride, e.g.,
|
|
70
|
+
|
|
71
|
+
class ExampleInput(InputsBaseModel):
|
|
72
|
+
points: list[Point] # datatype: "INT64"
|
|
73
|
+
more_points: Annotated[list[Point], DatatypeOverride("INT32")] # datatype: "INT32"
|
|
74
|
+
|
|
75
|
+
See DatatypeOverride docs for more information.
|
|
76
|
+
|
|
77
|
+
NOTE: Because there is just one datatype per input, it means that fields with a mix of types
|
|
78
|
+
like "tuple[int, float, str]" should be avoided.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
model_config = pydantic.ConfigDict(extra="forbid", frozen=True, coerce_numbers_to_str=True)
|
|
83
|
+
|
|
84
|
+
def to_inputs(self) -> list[OpenInferenceAPIInput]:
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
"name": field_name,
|
|
88
|
+
"shape": get_shape(field_value),
|
|
89
|
+
"datatype": get_datatype(field_value, type(self).model_fields[field_name]),
|
|
90
|
+
"data": get_data(field_value),
|
|
91
|
+
}
|
|
92
|
+
for field_name, field_value in self.model_dump(mode="json", warnings=False).items()
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class OutputsBaseModel(pydantic.BaseModel):
|
|
97
|
+
"""Base class for model outputs.
|
|
98
|
+
|
|
99
|
+
Subclass this class to define the model inputs, e.g.,
|
|
100
|
+
|
|
101
|
+
class MyModelIOutput(OutputsBaseModel):
|
|
102
|
+
values: list[float]
|
|
103
|
+
|
|
104
|
+
The field names MUST correspond to the names of the model outputs.
|
|
105
|
+
|
|
106
|
+
The structure of a field must be mappable to the shape of the
|
|
107
|
+
corresponding output, e.g.,
|
|
108
|
+
- "list[tuple[int, int]]" for a shape of [-1, 2],
|
|
109
|
+
- "tuple[float, float]" for a shape of [2], or
|
|
110
|
+
- "list[tuple[tuple[int, int], tuple[int, int]]]" for a shape of [-1, 2, 2].
|
|
111
|
+
|
|
112
|
+
NOTE: You can define an output field that is neither a list nor a tuple, just
|
|
113
|
+
a simple datatype like int, float, str; this will correspond to a shape of [1].
|
|
114
|
+
|
|
115
|
+
You can use typing.Namedtuple instead of a tuple to get named fields.
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
class Point(typing.Namedtuple):
|
|
119
|
+
x: float
|
|
120
|
+
y: float
|
|
121
|
+
|
|
122
|
+
class ExampleInput(OutputsBaseModel):
|
|
123
|
+
points: list[Point] # name: "points", shape: [-1, 2]
|
|
124
|
+
matrix: tuple[tuple[int, int, int], tuple[int, int, int]] # name: "matrix", shape: [2, 3]
|
|
125
|
+
text: str # name: "text", shape: [1]
|
|
126
|
+
|
|
127
|
+
You can take advantage of pydantic's type coercion by defining the field types
|
|
128
|
+
as you please, including mixed values.
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
Model outputs of the form
|
|
132
|
+
[
|
|
133
|
+
{
|
|
134
|
+
"name": "labels",
|
|
135
|
+
"datatype": "BYTES",
|
|
136
|
+
"shape": [3, 2],
|
|
137
|
+
"data": [
|
|
138
|
+
"SPAM",
|
|
139
|
+
"0.8",
|
|
140
|
+
"HAM",
|
|
141
|
+
"0.3",
|
|
142
|
+
"SPAM",
|
|
143
|
+
"0.9"
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
can be, using this OutputsBaseModel:
|
|
149
|
+
|
|
150
|
+
class LabelAndProbability(typing.Namedtuple):
|
|
151
|
+
label: typing.Literal["SPAM", "HAM"]
|
|
152
|
+
probability: float
|
|
153
|
+
|
|
154
|
+
class ExampleInput(OutputsBaseModel):
|
|
155
|
+
labels: list[LabelAndProbability]
|
|
156
|
+
|
|
157
|
+
coerced to
|
|
158
|
+
|
|
159
|
+
ExampleInput(
|
|
160
|
+
labels=[
|
|
161
|
+
LabelAndProbability(label="SPAM", probability=0.8),
|
|
162
|
+
LabelAndProbability(label="HAM", probability=0.3),
|
|
163
|
+
LabelAndProbability(label="SPAM", probability=0.9),
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
i.e., we get named fields of the desired type.
|
|
168
|
+
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
model_config = pydantic.ConfigDict(extra="forbid", frozen=True, coerce_numbers_to_str=True)
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def from_outputs(cls: type[Self], outputs: list[OpenInferenceAPIOutput]) -> Self:
|
|
175
|
+
values: dict[str, Any] = {}
|
|
176
|
+
for output in outputs:
|
|
177
|
+
value = unflatten_data(output["shape"], output["data"])
|
|
178
|
+
if output["shape"] == [1] and not is_listlike(cls.model_fields[output["name"]].annotation):
|
|
179
|
+
values[output["name"]] = value[0]
|
|
180
|
+
else:
|
|
181
|
+
values[output["name"]] = value
|
|
182
|
+
return cls(**values)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def get_requested_outputs(cls) -> list[OpenInferenceAPIRequestedOutput]:
|
|
186
|
+
return [{"name": field_name} for field_name in cls.model_fields]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
InputsModelT = TypeVar("InputsModelT", bound=InputsBaseModel)
|
|
190
|
+
OutputsModelT = TypeVar("OutputsModelT", bound=OutputsBaseModel)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class RemoteModel(Generic[InputsModelT, OutputsModelT]):
|
|
194
|
+
"""Interface toward a remote model called via the Open Inference protocol.
|
|
195
|
+
|
|
196
|
+
Define the server, model, inputs, and outputs, then call
|
|
197
|
+
the "infer" method to call the remote model, automatically
|
|
198
|
+
handling conversion between the inputs/outputs models and
|
|
199
|
+
the Open Inference protocol.
|
|
200
|
+
|
|
201
|
+
This class is designed to be thread-safe.
|
|
202
|
+
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__( # noqa: PLR0913
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
model_name: str,
|
|
209
|
+
inputs_model: type[InputsModelT],
|
|
210
|
+
outputs_model: type[OutputsModelT],
|
|
211
|
+
server_url: str,
|
|
212
|
+
request_timeout_seconds: float | None = None,
|
|
213
|
+
model_version: str | None = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Initialize a RemoteModel.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
model_name: The name of the model in the inference server.
|
|
219
|
+
inputs_model: InputsBaseModel subclass defining the model inputs.
|
|
220
|
+
outputs_model: OutputsBaseModel subclass defining the model outputs.
|
|
221
|
+
server_url: The URL of the inference server (not including the
|
|
222
|
+
API version "v2/..." and onward.)
|
|
223
|
+
request_timeout_seconds: Optional timeout (seconds) for requests to the inference server.
|
|
224
|
+
model_version: Optional model version.
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
self._model_name = model_name
|
|
228
|
+
self._model_version = model_version
|
|
229
|
+
self._inputs_model = inputs_model
|
|
230
|
+
self._outputs_model = outputs_model
|
|
231
|
+
self._client_API = OpenInferenceHTTPClientAPI(base_url=server_url)
|
|
232
|
+
self._request_timeout_seconds = request_timeout_seconds
|
|
233
|
+
|
|
234
|
+
def infer(self, inputs: InputsModelT) -> OutputsModelT:
|
|
235
|
+
"""Use the remote model for inference.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
inputs: The model inputs in the form of the InputsBaseModel
|
|
239
|
+
subclass defined for this RemoteModel instance.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
outputs: The model outputs in the form of the OutputsBaseModel
|
|
243
|
+
subclass defined for this RemoteModel instance.
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
if not isinstance(inputs, self._inputs_model):
|
|
247
|
+
raise TypeError(f"Bad inputs type: {type(inputs)}") # noqa: TRY003
|
|
248
|
+
return self._outputs_model.from_outputs(
|
|
249
|
+
self._client_API.infer(
|
|
250
|
+
model_name=self._model_name,
|
|
251
|
+
model_version=self._model_version,
|
|
252
|
+
inputs=inputs.to_inputs(),
|
|
253
|
+
outputs=self._outputs_model.get_requested_outputs(),
|
|
254
|
+
timeout_seconds=self._request_timeout_seconds,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, TypedDict
|
|
7
|
+
|
|
8
|
+
if sys.version_info < (3, 11):
|
|
9
|
+
from typing_extensions import NotRequired # pragma: no cover
|
|
10
|
+
else:
|
|
11
|
+
from typing import NotRequired # pragma: no cover
|
|
12
|
+
|
|
13
|
+
if sys.version_info < (3, 12):
|
|
14
|
+
from typing_extensions import override # pragma: no cover
|
|
15
|
+
else:
|
|
16
|
+
from typing import override # pragma: no cover
|
|
17
|
+
|
|
18
|
+
if sys.version_info < (3, 13):
|
|
19
|
+
from more_itertools import batched # pragma: no cover
|
|
20
|
+
else:
|
|
21
|
+
from itertools import batched # pragma: no cover
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
|
|
25
|
+
from pydantic.fields import FieldInfo
|
|
26
|
+
|
|
27
|
+
Shape = list[int]
|
|
28
|
+
Datatype = Literal[
|
|
29
|
+
"BOOL",
|
|
30
|
+
"UINT8",
|
|
31
|
+
"UINT16",
|
|
32
|
+
"UINT32",
|
|
33
|
+
"UINT64",
|
|
34
|
+
"INT8",
|
|
35
|
+
"INT16",
|
|
36
|
+
"INT32",
|
|
37
|
+
"INT64",
|
|
38
|
+
"FP16",
|
|
39
|
+
"FP32",
|
|
40
|
+
"FP64",
|
|
41
|
+
"BYTES",
|
|
42
|
+
]
|
|
43
|
+
Data = list[Any]
|
|
44
|
+
_TYPE_TO_DATATYPE_MAP: Final[Mapping[type, Datatype]] = {
|
|
45
|
+
bool: "BOOL",
|
|
46
|
+
int: "INT64",
|
|
47
|
+
float: "FP32",
|
|
48
|
+
str: "BYTES",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PydanticOpenInferenceError(Exception):
|
|
53
|
+
"""Package base exception."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ShapeDataMismatchError(PydanticOpenInferenceError):
|
|
57
|
+
"""Exception raised when shape and data do not match."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DatatypeOverride:
|
|
61
|
+
"""Use with typing.Annotated to override the default datatype in inputs.
|
|
62
|
+
|
|
63
|
+
In this example InputsBaseModel, the datatype of
|
|
64
|
+
"values" would be "INT64":
|
|
65
|
+
|
|
66
|
+
class IntInputsBaseModel(InputsBaseModel):
|
|
67
|
+
values: list[int]
|
|
68
|
+
|
|
69
|
+
Using DatatypeOverride, we can instead force the datatype
|
|
70
|
+
to be anything we want, e.g., "INT16":
|
|
71
|
+
|
|
72
|
+
class IntInputsBaseModel(InputsBaseModel):
|
|
73
|
+
values: Annotated[list[int], DatatypeOverride("INT16")]
|
|
74
|
+
|
|
75
|
+
Note that this simply sets the datatype as given. There
|
|
76
|
+
are no additional checks (for sign, size, etc).
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
__slots__ = ("_datatype",)
|
|
81
|
+
|
|
82
|
+
def __init__(self, datatype: Datatype) -> None:
|
|
83
|
+
self._datatype = datatype
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def datatype(self) -> Datatype:
|
|
87
|
+
return self._datatype
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_flat(data: Data) -> bool:
|
|
91
|
+
return not data or not any(isinstance(x, (list, tuple)) for x in data)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_row_major_order(shape: Shape, data: list[Any]) -> list[Any]:
|
|
95
|
+
if len(shape) == 1:
|
|
96
|
+
if shape[0] != len(data):
|
|
97
|
+
raise ShapeDataMismatchError
|
|
98
|
+
return data
|
|
99
|
+
new_shape: Shape = list(shape[:-1])
|
|
100
|
+
return parse_row_major_order(new_shape, list(batched(data, n=shape[-1], strict=True)))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_listlike(annotation: type[Any] | None) -> bool:
|
|
104
|
+
if annotation is None:
|
|
105
|
+
return False
|
|
106
|
+
if isinstance(annotation, types.GenericAlias):
|
|
107
|
+
annotation = annotation.__origin__ # type: ignore[unreachable]
|
|
108
|
+
return any(t in (list, tuple) for t in itertools.chain((annotation,), annotation.__bases__))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def unflatten_data(shape: Shape, data: Data) -> Data:
|
|
112
|
+
if is_flat(data):
|
|
113
|
+
return parse_row_major_order(shape, data)
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_shape(value: Any) -> Shape:
|
|
118
|
+
shape: Shape = []
|
|
119
|
+
while isinstance(value, (list, tuple)):
|
|
120
|
+
shape.append(len(value))
|
|
121
|
+
value = value[0]
|
|
122
|
+
return shape or [1]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_datatype(value: Any, field_info: FieldInfo) -> Datatype:
|
|
126
|
+
overrides: list[DatatypeOverride] = [x for x in field_info.metadata if isinstance(x, DatatypeOverride)]
|
|
127
|
+
if overrides:
|
|
128
|
+
return overrides[0].datatype
|
|
129
|
+
while isinstance(value, (list, tuple)):
|
|
130
|
+
value = value[0]
|
|
131
|
+
return _TYPE_TO_DATATYPE_MAP[type(value)]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_data(value: Any) -> Data:
|
|
135
|
+
if not isinstance(value, (tuple, list)):
|
|
136
|
+
return [value]
|
|
137
|
+
return list(value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _OpenInferenceAPIPut(TypedDict):
|
|
141
|
+
name: str
|
|
142
|
+
shape: Shape
|
|
143
|
+
datatype: Datatype
|
|
144
|
+
data: Data
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class OpenInferenceAPIInput(_OpenInferenceAPIPut):
|
|
148
|
+
parameters: NotRequired[dict[str, Any]]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class OpenInferenceAPIRequestedOutput(TypedDict):
|
|
152
|
+
name: str
|
|
153
|
+
parameters: NotRequired[dict[str, Any]]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class OpenInferenceAPIOutput(_OpenInferenceAPIPut):
|
|
157
|
+
parameters: NotRequired[dict[str, Any]]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Singleton(type):
|
|
161
|
+
_instances: ClassVar[dict[tuple[type, tuple[Any, ...], str], Singleton]] = {}
|
|
162
|
+
|
|
163
|
+
@override
|
|
164
|
+
def __call__(cls, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
165
|
+
key = (cls, args, str(kwargs))
|
|
166
|
+
if key not in cls._instances:
|
|
167
|
+
cls._instances[key] = super().__call__(*args, **kwargs)
|
|
168
|
+
return cls._instances[key]
|
|
File without changes
|