py-llmify 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mathis Kristoffer Arends
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
13
+ all 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
21
+ THE SOFTWARE.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-llmify
3
+ Version: 0.1.0
4
+ Summary: A minimal, fast, and type-safe Python library for LLM chat completions with OpenAI and Azure OpenAI support
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENCE
8
+ Requires-Dist: openai>=2.14.0
9
+ Requires-Dist: pydantic>=2.12.5
10
+ Requires-Dist: python-dotenv>=1.2.1
11
+ Dynamic: license-file
File without changes
@@ -0,0 +1,12 @@
1
+ from .messages import SystemMessage, UserMessage, AssistantMessage, ImageMessage
2
+ from .providers import ChatOpenAI, ChatAzureOpenAI, BaseChatModel
3
+
4
+ __all__ = [
5
+ "SystemMessage",
6
+ "UserMessage",
7
+ "AssistantMessage",
8
+ "ImageMessage",
9
+ "ChatOpenAI",
10
+ "ChatAzureOpenAI",
11
+ "BaseChatModel",
12
+ ]
@@ -0,0 +1,65 @@
1
+ import base64
2
+ from dataclasses import dataclass
3
+ from enum import StrEnum
4
+ from typing import Literal
5
+
6
+
7
+ class _MessageRole(StrEnum):
8
+ SYSTEM = "system"
9
+ USER = "user"
10
+ ASSISTANT = "assistant"
11
+
12
+
13
+ _MediaType = Literal["image/jpeg", "image/png"]
14
+
15
+
16
+ @dataclass
17
+ class Message:
18
+ role: _MessageRole
19
+ content: str
20
+
21
+
22
+ class SystemMessage(Message):
23
+ def __init__(self, content: str):
24
+ super().__init__(role=_MessageRole.SYSTEM, content=content)
25
+
26
+
27
+ class UserMessage(Message):
28
+ def __init__(self, content: str):
29
+ super().__init__(role=_MessageRole.USER, content=content)
30
+
31
+
32
+ class AssistantMessage(Message):
33
+ def __init__(self, content: str):
34
+ super().__init__(role=_MessageRole.ASSISTANT, content=content)
35
+
36
+
37
+ @dataclass
38
+ class ImageMessage(Message):
39
+ base64_data: str
40
+ media_type: _MediaType
41
+
42
+ def __init__(
43
+ self,
44
+ base64_data: str,
45
+ media_type: _MediaType | None = None,
46
+ text: str | None = None,
47
+ ):
48
+ self.base64_data = base64_data
49
+
50
+ if media_type is None:
51
+ self.media_type = self._detect_media_type(base64_data)
52
+ else:
53
+ self.media_type = media_type
54
+
55
+ super().__init__(role=_MessageRole.USER, content=text or "")
56
+
57
+ @staticmethod
58
+ def _detect_media_type(base64_data: str) -> _MediaType:
59
+ try:
60
+ header = base64.b64decode(base64_data[:20])
61
+ if header.startswith(b"\x89PNG"):
62
+ return "image/png"
63
+ except Exception:
64
+ pass
65
+ return "image/jpeg"
@@ -0,0 +1,11 @@
1
+ from .openai import ChatOpenAI
2
+ from .azure import ChatAzureOpenAI
3
+
4
+ from .base import BaseChatModel, BaseOpenAICompatible
5
+
6
+ __all__ = [
7
+ "ChatOpenAI",
8
+ "ChatAzureOpenAI",
9
+ "BaseChatModel",
10
+ "BaseOpenAICompatible",
11
+ ]
@@ -0,0 +1,55 @@
1
+ import os
2
+ import httpx
3
+ from openai import AsyncAzureOpenAI
4
+ from llmify.providers.base import BaseOpenAICompatible
5
+ from typing import Any
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv(override=True)
9
+
10
+
11
+ class ChatAzureOpenAI(BaseOpenAICompatible):
12
+ def __init__(
13
+ self,
14
+ model: str = "gpt-4o",
15
+ api_key: str | None = None,
16
+ azure_endpoint: str | None = None,
17
+ api_version: str = "2024-02-15-preview",
18
+ max_tokens: int | None = None,
19
+ temperature: float | None = None,
20
+ top_p: float | None = None,
21
+ frequency_penalty: float | None = None,
22
+ presence_penalty: float | None = None,
23
+ stop: str | list[str] | None = None,
24
+ seed: int | None = None,
25
+ response_format: dict | None = None,
26
+ timeout: float | httpx.Timeout | None = 60.0,
27
+ max_retries: int = 2,
28
+ **kwargs: Any,
29
+ ):
30
+ super().__init__(
31
+ max_tokens=max_tokens,
32
+ temperature=temperature,
33
+ top_p=top_p,
34
+ frequency_penalty=frequency_penalty,
35
+ presence_penalty=presence_penalty,
36
+ stop=stop,
37
+ seed=seed,
38
+ response_format=response_format,
39
+ timeout=timeout,
40
+ max_retries=max_retries,
41
+ **kwargs,
42
+ )
43
+ if api_key is None:
44
+ api_key = os.getenv("AZURE_OPENAI_API_KEY")
45
+ if azure_endpoint is None:
46
+ azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
47
+
48
+ self._client = AsyncAzureOpenAI(
49
+ api_key=api_key,
50
+ azure_endpoint=azure_endpoint,
51
+ api_version=api_version,
52
+ timeout=timeout,
53
+ max_retries=max_retries,
54
+ )
55
+ self._model = model
@@ -0,0 +1,175 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import TypeVar, Any
3
+
4
+ from collections.abc import AsyncIterator
5
+ from pydantic import BaseModel
6
+ import httpx
7
+ from openai import AsyncOpenAI, AsyncAzureOpenAI
8
+ from openai.types.chat import ChatCompletion, ChatCompletionChunk
9
+
10
+ from llmify.messages import Message, ImageMessage
11
+
12
+ T = TypeVar("T", bound=BaseModel)
13
+
14
+
15
+ class BaseChatModel(ABC):
16
+ def __init__(
17
+ self,
18
+ max_tokens: int | None = None,
19
+ temperature: float | None = None,
20
+ top_p: float | None = None,
21
+ frequency_penalty: float | None = None,
22
+ presence_penalty: float | None = None,
23
+ stop: str | list[str] | None = None,
24
+ seed: int | None = None,
25
+ response_format: dict | None = None,
26
+ timeout: float | httpx.Timeout | None = 60.0,
27
+ max_retries: int = 2,
28
+ **kwargs: Any,
29
+ ):
30
+ self._default_max_tokens = max_tokens
31
+ self._default_temperature = temperature
32
+ self._default_top_p = top_p
33
+ self._default_frequency_penalty = frequency_penalty
34
+ self._default_presence_penalty = presence_penalty
35
+ self._default_stop = stop
36
+ self._default_seed = seed
37
+ self._default_response_format = response_format
38
+ self._default_timeout = timeout
39
+ self._default_max_retries = max_retries
40
+ self._default_kwargs = kwargs
41
+
42
+ def _merge_params(self, method_kwargs: dict[str, Any]) -> dict[str, Any]:
43
+ params = {**self._default_kwargs, **method_kwargs}
44
+
45
+ param_mapping = {
46
+ "max_tokens": self._default_max_tokens,
47
+ "temperature": self._default_temperature,
48
+ "top_p": self._default_top_p,
49
+ "frequency_penalty": self._default_frequency_penalty,
50
+ "presence_penalty": self._default_presence_penalty,
51
+ "stop": self._default_stop,
52
+ "seed": self._default_seed,
53
+ "response_format": self._default_response_format,
54
+ }
55
+
56
+ for key, default_value in param_mapping.items():
57
+ if key not in method_kwargs and default_value is not None:
58
+ params[key] = default_value
59
+
60
+ params = {k: v for k, v in params.items() if v is not None}
61
+ return params
62
+
63
+ @abstractmethod
64
+ async def invoke(
65
+ self,
66
+ messages: list[Message],
67
+ max_tokens: int | None = None,
68
+ temperature: float | None = None,
69
+ **kwargs: Any,
70
+ ) -> str:
71
+ pass
72
+
73
+ @abstractmethod
74
+ async def invoke_structured(
75
+ self,
76
+ messages: list[Message],
77
+ response_model: type[T],
78
+ max_tokens: int | None = None,
79
+ temperature: float | None = None,
80
+ **kwargs: Any,
81
+ ) -> T:
82
+ pass
83
+
84
+ @abstractmethod
85
+ async def stream(
86
+ self,
87
+ messages: list[Message],
88
+ max_tokens: int | None = None,
89
+ temperature: float | None = None,
90
+ **kwargs: Any,
91
+ ) -> AsyncIterator[str]:
92
+ pass
93
+
94
+
95
+ class BaseOpenAICompatible(BaseChatModel):
96
+ _client: AsyncOpenAI | AsyncAzureOpenAI
97
+ _model: str
98
+
99
+ def _convert_messages(self, messages: list[Message]) -> list[dict]:
100
+ converted = []
101
+ for msg in messages:
102
+ if not isinstance(msg, ImageMessage):
103
+ converted.append({"role": msg.role, "content": msg.content})
104
+ continue
105
+
106
+ content = []
107
+ if msg.content:
108
+ content.append({"type": "text", "text": msg.content})
109
+ content.append(
110
+ {
111
+ "type": "image_url",
112
+ "image_url": {
113
+ "url": f"data:{msg.media_type};base64,{msg.base64_data}"
114
+ },
115
+ }
116
+ )
117
+ converted.append({"role": msg.role, "content": content})
118
+
119
+ return converted
120
+
121
+ async def invoke(
122
+ self,
123
+ messages: list[Message],
124
+ max_tokens: int | None = None,
125
+ temperature: float | None = None,
126
+ **kwargs: Any,
127
+ ) -> str:
128
+ params = self._merge_params(
129
+ {"max_tokens": max_tokens, "temperature": temperature, **kwargs}
130
+ )
131
+ response: ChatCompletion = await self._client.chat.completions.create(
132
+ model=self._model, messages=self._convert_messages(messages), **params
133
+ )
134
+ return response.choices[0].message.content or ""
135
+
136
+ async def invoke_structured(
137
+ self,
138
+ messages: list[Message],
139
+ response_model: type[T],
140
+ max_tokens: int | None = None,
141
+ temperature: float | None = None,
142
+ **kwargs: Any,
143
+ ) -> T:
144
+ params = self._merge_params(
145
+ {"max_tokens": max_tokens, "temperature": temperature, **kwargs}
146
+ )
147
+ response = await self._client.beta.chat.completions.parse(
148
+ model=self._model,
149
+ messages=self._convert_messages(messages),
150
+ response_format=response_model,
151
+ **params,
152
+ )
153
+ return response.choices[0].message.parsed
154
+
155
+ async def stream(
156
+ self,
157
+ messages: list[Message],
158
+ max_tokens: int | None = None,
159
+ temperature: float | None = None,
160
+ **kwargs: Any,
161
+ ) -> AsyncIterator[str]:
162
+ params = self._merge_params(
163
+ {"max_tokens": max_tokens, "temperature": temperature, **kwargs}
164
+ )
165
+ stream = await self._client.chat.completions.create(
166
+ model=self._model,
167
+ messages=self._convert_messages(messages),
168
+ stream=True,
169
+ **params,
170
+ )
171
+ chunk: ChatCompletionChunk
172
+ async for chunk in stream:
173
+ content = chunk.choices[0].delta.content
174
+ if content is not None:
175
+ yield content
@@ -0,0 +1,51 @@
1
+ import os
2
+ import httpx
3
+ from openai import AsyncOpenAI
4
+ from llmify.providers.base import BaseOpenAICompatible
5
+ from typing import Any
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv(override=True)
9
+
10
+
11
+ class ChatOpenAI(BaseOpenAICompatible):
12
+ def __init__(
13
+ self,
14
+ model: str = "gpt-4o",
15
+ api_key: str | None = None,
16
+ max_tokens: int | None = None,
17
+ temperature: float | None = None,
18
+ top_p: float | None = None,
19
+ frequency_penalty: float | None = None,
20
+ presence_penalty: float | None = None,
21
+ stop: str | list[str] | None = None,
22
+ seed: int | None = None,
23
+ response_format: dict | None = None,
24
+ timeout: float | httpx.Timeout | None = 60.0,
25
+ max_retries: int = 2,
26
+ default_headers: dict | None = None,
27
+ **kwargs: Any,
28
+ ):
29
+ super().__init__(
30
+ max_tokens=max_tokens,
31
+ temperature=temperature,
32
+ top_p=top_p,
33
+ frequency_penalty=frequency_penalty,
34
+ presence_penalty=presence_penalty,
35
+ stop=stop,
36
+ seed=seed,
37
+ response_format=response_format,
38
+ timeout=timeout,
39
+ max_retries=max_retries,
40
+ **kwargs,
41
+ )
42
+ if api_key is None:
43
+ api_key = os.getenv("OPENAI_API_KEY")
44
+
45
+ self._client = AsyncOpenAI(
46
+ api_key=api_key,
47
+ timeout=timeout,
48
+ max_retries=max_retries,
49
+ default_headers=default_headers,
50
+ )
51
+ self._model = model
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-llmify
3
+ Version: 0.1.0
4
+ Summary: A minimal, fast, and type-safe Python library for LLM chat completions with OpenAI and Azure OpenAI support
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENCE
8
+ Requires-Dist: openai>=2.14.0
9
+ Requires-Dist: pydantic>=2.12.5
10
+ Requires-Dist: python-dotenv>=1.2.1
11
+ Dynamic: license-file
@@ -0,0 +1,18 @@
1
+ LICENCE
2
+ README.md
3
+ pyproject.toml
4
+ llmify/__init__.py
5
+ llmify/messages.py
6
+ llmify/providers/__init__.py
7
+ llmify/providers/azure.py
8
+ llmify/providers/base.py
9
+ llmify/providers/openai.py
10
+ py_llmify.egg-info/PKG-INFO
11
+ py_llmify.egg-info/SOURCES.txt
12
+ py_llmify.egg-info/dependency_links.txt
13
+ py_llmify.egg-info/requires.txt
14
+ py_llmify.egg-info/top_level.txt
15
+ tests/test_messages.py
16
+ tests/test_openai_integration.py
17
+ tests/test_params.py
18
+ tests/test_providers.py
@@ -0,0 +1,3 @@
1
+ openai>=2.14.0
2
+ pydantic>=2.12.5
3
+ python-dotenv>=1.2.1
@@ -0,0 +1 @@
1
+ llmify
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "py-llmify"
3
+ version = "0.1.0"
4
+ description = "A minimal, fast, and type-safe Python library for LLM chat completions with OpenAI and Azure OpenAI support"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "openai>=2.14.0",
9
+ "pydantic>=2.12.5",
10
+ "python-dotenv>=1.2.1",
11
+ ]
12
+
13
+ [dependency-groups]
14
+ dev = [
15
+ "pre-commit>=4.5.1",
16
+ "pytest>=9.0.2",
17
+ "pytest-asyncio>=1.3.0",
18
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ from llmify import UserMessage, SystemMessage, ImageMessage
2
+
3
+
4
+ def test_user_message():
5
+ msg = UserMessage("Hello")
6
+ assert msg.role == "user"
7
+ assert msg.content == "Hello"
8
+
9
+
10
+ def test_system_message():
11
+ msg = SystemMessage("You are helpful")
12
+ assert msg.role == "system"
13
+ assert msg.content == "You are helpful"
14
+
15
+
16
+ def test_image_message_with_text():
17
+ msg = ImageMessage(base64_data="abc123", text="What is this?")
18
+ assert msg.role == "user"
19
+ assert msg.content == "What is this?"
20
+ assert msg.base64_data == "abc123"
21
+
22
+
23
+ def test_image_message_auto_detect_png():
24
+ png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
25
+ msg = ImageMessage(base64_data=png_base64)
26
+ assert msg.media_type == "image/png"
@@ -0,0 +1,40 @@
1
+ import pytest
2
+ from unittest.mock import AsyncMock, patch
3
+ from llmify import ChatOpenAI, UserMessage
4
+
5
+
6
+ @pytest.mark.asyncio
7
+ @patch("llmify.providers.openai.AsyncOpenAI")
8
+ async def test_invoke_with_mock(mock_client):
9
+ mock_response = AsyncMock()
10
+ mock_response.choices = [AsyncMock(message=AsyncMock(content="Hello!"))]
11
+ mock_client.return_value.chat.completions.create = AsyncMock(
12
+ return_value=mock_response
13
+ )
14
+
15
+ llm = ChatOpenAI(api_key="fake-key")
16
+ response = await llm.invoke([UserMessage("Hi")])
17
+
18
+ assert response == "Hello!"
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ @patch("llmify.providers.openai.AsyncOpenAI")
23
+ async def test_streaming_with_mock(mock_client):
24
+ chunk1 = AsyncMock(choices=[AsyncMock(delta=AsyncMock(content="Hel"))])
25
+ chunk2 = AsyncMock(choices=[AsyncMock(delta=AsyncMock(content="lo!"))])
26
+
27
+ async def mock_stream(*args, **kwargs):
28
+ yield chunk1
29
+ yield chunk2
30
+
31
+ mock_client.return_value.chat.completions.create = AsyncMock(
32
+ return_value=mock_stream()
33
+ )
34
+
35
+ llm = ChatOpenAI(api_key="fake-key")
36
+ chunks = []
37
+ async for chunk in llm.stream([UserMessage("Hi")]):
38
+ chunks.append(chunk)
39
+
40
+ assert chunks == ["Hel", "lo!"]
@@ -0,0 +1,35 @@
1
+ from llmify import BaseChatModel
2
+
3
+
4
+ class DummyModel(BaseChatModel):
5
+ async def invoke(self, messages, **kwargs):
6
+ return self._merge_params(kwargs)
7
+
8
+ async def invoke_structured(self, messages, response_model, **kwargs):
9
+ pass
10
+
11
+ async def stream(self, messages, **kwargs):
12
+ pass
13
+
14
+
15
+ def test_default_params():
16
+ model = DummyModel(max_tokens=100, temperature=0.7)
17
+ params = model._merge_params({})
18
+
19
+ assert params["max_tokens"] == 100
20
+ assert params["temperature"] == 0.7
21
+
22
+
23
+ def test_override_params():
24
+ model = DummyModel(max_tokens=100, temperature=0.7)
25
+ params = model._merge_params({"max_tokens": 200})
26
+
27
+ assert params["max_tokens"] == 200
28
+ assert params["temperature"] == 0.7
29
+
30
+
31
+ def test_none_params_filtered():
32
+ model = DummyModel(max_tokens=None)
33
+ params = model._merge_params({})
34
+
35
+ assert "max_tokens" not in params
@@ -0,0 +1,32 @@
1
+ from llmify.providers import BaseOpenAICompatible
2
+ from llmify import UserMessage, ImageMessage
3
+
4
+
5
+ class DummyOpenAI(BaseOpenAICompatible):
6
+ pass
7
+
8
+
9
+ def test_convert_simple_messages():
10
+ provider = DummyOpenAI()
11
+ messages = [UserMessage("Hello")]
12
+
13
+ converted = provider._convert_messages(messages)
14
+
15
+ assert converted == [{"role": "user", "content": "Hello"}]
16
+
17
+
18
+ def test_convert_image_message():
19
+ provider = DummyOpenAI()
20
+ messages = [
21
+ ImageMessage(base64_data="abc123", media_type="image/png", text="What is this?")
22
+ ]
23
+
24
+ converted = provider._convert_messages(messages)
25
+
26
+ assert converted[0]["role"] == "user"
27
+ assert len(converted[0]["content"]) == 2
28
+ assert converted[0]["content"][0] == {"type": "text", "text": "What is this?"}
29
+ assert converted[0]["content"][1]["type"] == "image_url"
30
+ assert (
31
+ "data:image/png;base64,abc123" in converted[0]["content"][1]["image_url"]["url"]
32
+ )