emberio-labs-ember 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) 2026 Emberio Labs
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,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: emberio-labs-ember
3
+ Version: 0.1.0
4
+ Summary: Библиотека для простой интеграции с LLM-провайдерами и создания собственных агентов
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: Ember Labs
8
+ Author-email: dev@emberio.labs
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Provides-Extra: openai
18
+ Requires-Dist: openai (>=3.0,<4.0) ; extra == "openai"
19
+ Project-URL: Homepage, https://github.com/emberio-labs/ember
20
+ Project-URL: Repository, https://github.com/emberio-labs/ember
21
+ Description-Content-Type: text/markdown
22
+
23
+ # ember
24
+
25
+ Библиотека для простой интеграции с LLM-провайдерами и создания собственных агентов.
26
+ Предоставляет простой и единообразный интерфейс для работы с языковыми моделями,
27
+ чтобы вы могли сосредоточиться на логике своих агентов, а не на деталях API.
28
+
29
+ ## Возможности
30
+
31
+ - Единый интерфейс для различных LLM-провайдеров
32
+ - Простой способ создавать и конфигурировать собственных агентов
33
+ - Инструменты/функции для модели (tool calling)
34
+ - Минимальное количество кода для старта
35
+
36
+ > ⚠️ Проект на ранней стадии разработки. API активно меняется.
37
+
38
+ ## Установка
39
+
40
+ На PyPI пакет публикуется под именем `emberio-labs-ember` (импорт в коде — `ember`):
41
+
42
+ ```bash
43
+ pip install "emberio-labs-ember[openai]" # с поддержкой OpenAI
44
+ pip install emberio-labs-ember # ядро (без провайдеров)
45
+ ```
46
+
47
+ Для разработки (из репозитория) проект управляется через
48
+ [Poetry](https://python-poetry.org/):
49
+
50
+ ```bash
51
+ poetry install
52
+ ```
53
+
54
+ ## Быстрый старт
55
+
56
+ ### Без ключей: `MockProvider`
57
+
58
+ Работает без сети и API-ключей — удобно для экспериментов:
59
+
60
+ ```python
61
+ from ember import Agent, MockProvider
62
+
63
+ agent = Agent(provider=MockProvider(response_text="Привет! Я мок-провайдер."))
64
+ print(agent.run("Привет!"))
65
+ # Привет! Я мок-провайдер.
66
+ ```
67
+
68
+ ### С OpenAI: `OpenAIProvider`
69
+
70
+ API-ключ передаётся явно (провайдер сам не читает окружение):
71
+
72
+ ```python
73
+ import os
74
+
75
+ from ember import Agent, OpenAIProvider
76
+
77
+ agent = Agent(
78
+ provider=OpenAIProvider(api_key=os.environ["OPENAI_API_KEY"]),
79
+ model="gpt-4o-mini",
80
+ )
81
+ print(agent.run("Расскажи о себе в одном предложении."))
82
+ ```
83
+
84
+ Полный исполняемый пример — в [`examples/quickstart.py`](examples/quickstart.py).
85
+
86
+ ### История диалога
87
+
88
+ `Agent` сам накапливает историю: сообщения пользователя и ответы модели
89
+ добавляются в `agent.messages`. Сбросить диалог можно через `agent.reset()`.
90
+ Системный промпт задаётся в конструкторе:
91
+
92
+ ```python
93
+ agent = Agent(
94
+ provider=MockProvider(),
95
+ system_prompt="Ты краткий и полезный помощник.",
96
+ )
97
+ ```
98
+
99
+ ### Инструменты (tool calling)
100
+
101
+ Модель можно научить вызывать функции. Опишите инструмент через `Tool`
102
+ и передайте список в запрос:
103
+
104
+ ```python
105
+ from ember import ChatRequest, Message, Tool
106
+
107
+ tools = [
108
+ Tool(
109
+ name="get_weather",
110
+ description="Погода в городе",
111
+ parameters={
112
+ "type": "object",
113
+ "properties": {"city": {"type": "string"}},
114
+ "required": ["city"],
115
+ },
116
+ )
117
+ ]
118
+
119
+ # provider — любой Provider, например OpenAIProvider(api_key=...)
120
+ response = provider.complete(
121
+ ChatRequest(
122
+ messages=[Message(role="user", content="Какая погода в Москве?")],
123
+ model="gpt-4o-mini",
124
+ tools=tools,
125
+ )
126
+ )
127
+ # Если модель решила вызвать инструмент, вызовы будут в response.message.tool_calls
128
+ if response.message.tool_calls:
129
+ for call in response.message.tool_calls:
130
+ print(call.name, call.arguments)
131
+ ```
132
+
133
+ Результат выполнения возвращается модели сообщением с ролью `tool`
134
+ и идентификатором вызова:
135
+
136
+ ```python
137
+ Message(role="tool", content="+15C", tool_call_id=call.id)
138
+ ```
139
+
140
+ ## Разработка
141
+
142
+ ```bash
143
+ # Установка зависимостей (включая dev)
144
+ poetry install --with dev
145
+
146
+ # Запуск тестов
147
+ poetry run pytest
148
+
149
+ # Линтинг
150
+ poetry run ruff check .
151
+ poetry run ruff format --check .
152
+
153
+ # Проверка типов
154
+ poetry run mypy ember
155
+ ```
156
+
157
+ CI (GitHub Actions) автоматически прогоняет линтинг, проверку типов и тесты
158
+ на Python 3.10–3.12 для каждого pull request.
159
+
160
+ ## Релиз
161
+
162
+ Публикация новой версии на PyPI автоматизирована через GitHub Actions
163
+ (workflow `.github/workflows/publish.yml`):
164
+
165
+ 1. Поднимите версию в `pyproject.toml` (`version = "0.1.0"`) и закоммитьте
166
+ изменение, например: `chore: bump version to 0.1.0`
167
+ 2. Создайте и запушьте git-тег, совпадающий с версией:
168
+
169
+ ```bash
170
+ git tag v0.1.0
171
+ git push origin v0.1.0
172
+ ```
173
+
174
+ 3. Workflow соберёт wheel и sdist (`poetry build`) и опубликует их на PyPI.
175
+ Ветка `main` при этом не нужна — достаточно тега.
176
+
177
+ Публикация использует Trusted Publishing (OIDC): секреты в GitHub не хранятся.
178
+ Для этого владельцу нужно один раз настроить publisher на PyPI
179
+ (и, опционально, на TestPyPI для проверок):
180
+
181
+ - **PyPI:** https://pypi.org/manage/account/publishing/
182
+ - **TestPyPI:** https://test.pypi.org/manage/account/publishing/
183
+
184
+ Поля формы одинаковы для PyPI и TestPyPI:
185
+
186
+ | Поле | Значение |
187
+ |---|---|
188
+ | Project name | `emberio-labs-ember` |
189
+ | GitHub owner | `emberio-labs` |
190
+ | GitHub repository | `ember` |
191
+ | Workflow name | `publish.yml` |
192
+ | Environment | *(пусто)* |
193
+
194
+ После настройки публикацию можно проверить вручную на TestPyPI:
195
+ GitHub → Actions → Publish → Run workflow. На боевой PyPI пакет уходит
196
+ только по git-тегу `v*`.
197
+
198
+ ## Лицензия
199
+
200
+ Проект распространяется под лицензией [MIT](LICENSE).
201
+
202
+ © 2026 Emberio Labs
203
+
@@ -0,0 +1,180 @@
1
+ # ember
2
+
3
+ Библиотека для простой интеграции с LLM-провайдерами и создания собственных агентов.
4
+ Предоставляет простой и единообразный интерфейс для работы с языковыми моделями,
5
+ чтобы вы могли сосредоточиться на логике своих агентов, а не на деталях API.
6
+
7
+ ## Возможности
8
+
9
+ - Единый интерфейс для различных LLM-провайдеров
10
+ - Простой способ создавать и конфигурировать собственных агентов
11
+ - Инструменты/функции для модели (tool calling)
12
+ - Минимальное количество кода для старта
13
+
14
+ > ⚠️ Проект на ранней стадии разработки. API активно меняется.
15
+
16
+ ## Установка
17
+
18
+ На PyPI пакет публикуется под именем `emberio-labs-ember` (импорт в коде — `ember`):
19
+
20
+ ```bash
21
+ pip install "emberio-labs-ember[openai]" # с поддержкой OpenAI
22
+ pip install emberio-labs-ember # ядро (без провайдеров)
23
+ ```
24
+
25
+ Для разработки (из репозитория) проект управляется через
26
+ [Poetry](https://python-poetry.org/):
27
+
28
+ ```bash
29
+ poetry install
30
+ ```
31
+
32
+ ## Быстрый старт
33
+
34
+ ### Без ключей: `MockProvider`
35
+
36
+ Работает без сети и API-ключей — удобно для экспериментов:
37
+
38
+ ```python
39
+ from ember import Agent, MockProvider
40
+
41
+ agent = Agent(provider=MockProvider(response_text="Привет! Я мок-провайдер."))
42
+ print(agent.run("Привет!"))
43
+ # Привет! Я мок-провайдер.
44
+ ```
45
+
46
+ ### С OpenAI: `OpenAIProvider`
47
+
48
+ API-ключ передаётся явно (провайдер сам не читает окружение):
49
+
50
+ ```python
51
+ import os
52
+
53
+ from ember import Agent, OpenAIProvider
54
+
55
+ agent = Agent(
56
+ provider=OpenAIProvider(api_key=os.environ["OPENAI_API_KEY"]),
57
+ model="gpt-4o-mini",
58
+ )
59
+ print(agent.run("Расскажи о себе в одном предложении."))
60
+ ```
61
+
62
+ Полный исполняемый пример — в [`examples/quickstart.py`](examples/quickstart.py).
63
+
64
+ ### История диалога
65
+
66
+ `Agent` сам накапливает историю: сообщения пользователя и ответы модели
67
+ добавляются в `agent.messages`. Сбросить диалог можно через `agent.reset()`.
68
+ Системный промпт задаётся в конструкторе:
69
+
70
+ ```python
71
+ agent = Agent(
72
+ provider=MockProvider(),
73
+ system_prompt="Ты краткий и полезный помощник.",
74
+ )
75
+ ```
76
+
77
+ ### Инструменты (tool calling)
78
+
79
+ Модель можно научить вызывать функции. Опишите инструмент через `Tool`
80
+ и передайте список в запрос:
81
+
82
+ ```python
83
+ from ember import ChatRequest, Message, Tool
84
+
85
+ tools = [
86
+ Tool(
87
+ name="get_weather",
88
+ description="Погода в городе",
89
+ parameters={
90
+ "type": "object",
91
+ "properties": {"city": {"type": "string"}},
92
+ "required": ["city"],
93
+ },
94
+ )
95
+ ]
96
+
97
+ # provider — любой Provider, например OpenAIProvider(api_key=...)
98
+ response = provider.complete(
99
+ ChatRequest(
100
+ messages=[Message(role="user", content="Какая погода в Москве?")],
101
+ model="gpt-4o-mini",
102
+ tools=tools,
103
+ )
104
+ )
105
+ # Если модель решила вызвать инструмент, вызовы будут в response.message.tool_calls
106
+ if response.message.tool_calls:
107
+ for call in response.message.tool_calls:
108
+ print(call.name, call.arguments)
109
+ ```
110
+
111
+ Результат выполнения возвращается модели сообщением с ролью `tool`
112
+ и идентификатором вызова:
113
+
114
+ ```python
115
+ Message(role="tool", content="+15C", tool_call_id=call.id)
116
+ ```
117
+
118
+ ## Разработка
119
+
120
+ ```bash
121
+ # Установка зависимостей (включая dev)
122
+ poetry install --with dev
123
+
124
+ # Запуск тестов
125
+ poetry run pytest
126
+
127
+ # Линтинг
128
+ poetry run ruff check .
129
+ poetry run ruff format --check .
130
+
131
+ # Проверка типов
132
+ poetry run mypy ember
133
+ ```
134
+
135
+ CI (GitHub Actions) автоматически прогоняет линтинг, проверку типов и тесты
136
+ на Python 3.10–3.12 для каждого pull request.
137
+
138
+ ## Релиз
139
+
140
+ Публикация новой версии на PyPI автоматизирована через GitHub Actions
141
+ (workflow `.github/workflows/publish.yml`):
142
+
143
+ 1. Поднимите версию в `pyproject.toml` (`version = "0.1.0"`) и закоммитьте
144
+ изменение, например: `chore: bump version to 0.1.0`
145
+ 2. Создайте и запушьте git-тег, совпадающий с версией:
146
+
147
+ ```bash
148
+ git tag v0.1.0
149
+ git push origin v0.1.0
150
+ ```
151
+
152
+ 3. Workflow соберёт wheel и sdist (`poetry build`) и опубликует их на PyPI.
153
+ Ветка `main` при этом не нужна — достаточно тега.
154
+
155
+ Публикация использует Trusted Publishing (OIDC): секреты в GitHub не хранятся.
156
+ Для этого владельцу нужно один раз настроить publisher на PyPI
157
+ (и, опционально, на TestPyPI для проверок):
158
+
159
+ - **PyPI:** https://pypi.org/manage/account/publishing/
160
+ - **TestPyPI:** https://test.pypi.org/manage/account/publishing/
161
+
162
+ Поля формы одинаковы для PyPI и TestPyPI:
163
+
164
+ | Поле | Значение |
165
+ |---|---|
166
+ | Project name | `emberio-labs-ember` |
167
+ | GitHub owner | `emberio-labs` |
168
+ | GitHub repository | `ember` |
169
+ | Workflow name | `publish.yml` |
170
+ | Environment | *(пусто)* |
171
+
172
+ После настройки публикацию можно проверить вручную на TestPyPI:
173
+ GitHub → Actions → Publish → Run workflow. На боевой PyPI пакет уходит
174
+ только по git-тегу `v*`.
175
+
176
+ ## Лицензия
177
+
178
+ Проект распространяется под лицензией [MIT](LICENSE).
179
+
180
+ © 2026 Emberio Labs
@@ -0,0 +1,43 @@
1
+ """ember — библиотека для простой интеграции с LLM-провайдерами и создания агентов."""
2
+
3
+ from ember.agent import Agent, ToolCallLimitError
4
+ from ember.providers import (
5
+ MockProvider,
6
+ OpenAIProvider,
7
+ Provider,
8
+ ProviderError,
9
+ get_provider,
10
+ register_provider,
11
+ )
12
+ from ember.types import (
13
+ ChatRequest,
14
+ ChatResponse,
15
+ FunctionTool,
16
+ Message,
17
+ StreamChunk,
18
+ Tool,
19
+ ToolCall,
20
+ Usage,
21
+ )
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "__version__",
27
+ "Agent",
28
+ "ChatRequest",
29
+ "ChatResponse",
30
+ "FunctionTool",
31
+ "Message",
32
+ "MockProvider",
33
+ "OpenAIProvider",
34
+ "Provider",
35
+ "ProviderError",
36
+ "StreamChunk",
37
+ "Tool",
38
+ "ToolCall",
39
+ "ToolCallLimitError",
40
+ "Usage",
41
+ "get_provider",
42
+ "register_provider",
43
+ ]
@@ -0,0 +1,195 @@
1
+ """Простой агент поверх Provider — минимум кода для чат-диалога."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterator
7
+ from typing import Any
8
+
9
+ from ember.providers.base import Provider
10
+ from ember.types import ChatRequest, FunctionTool, Message, ToolCall
11
+
12
+
13
+ class ToolCallLimitError(RuntimeError):
14
+ """Модель превысила лимит раундов tool calling.
15
+
16
+ Поднимается, когда модель снова и снова запрашивает инструменты вместо
17
+ финального текстового ответа: она зациклилась либо задача не решается
18
+ доступными инструментами за отведённое число шагов.
19
+ """
20
+
21
+
22
+ class Agent:
23
+ """Чат-агент, работающий поверх любого ``Provider``.
24
+
25
+ Хранит историю диалога и системный промпт, формирует ``ChatRequest``
26
+ и возвращает текстовый ответ модели.
27
+
28
+ Если агенту заданы инструменты (``tools``), ``run()`` исполняет
29
+ запрошенные моделью вызовы в цикле: результат каждого инструмента
30
+ возвращается модели как tool-сообщение, и диалог продолжается до тех
31
+ пор, пока модель не даст финальный текстовый ответ.
32
+
33
+ Attributes:
34
+ provider: Провайдер, через который агент общается с моделью.
35
+ model: Модель по умолчанию. Если не задана, провайдер использует
36
+ свою модель по умолчанию.
37
+ tools: Инструменты, доступные модели (описание + функция).
38
+ max_tool_steps: Максимум раундов исполнения инструментов за один
39
+ ``run()``. Один раунд — один запрос к модели и исполнение всех
40
+ запрошенных в ответе вызовов.
41
+ messages: Текущая история диалога (включая system-сообщение).
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ provider: Provider,
47
+ system_prompt: str = "",
48
+ model: str | None = None,
49
+ tools: list[FunctionTool] | None = None,
50
+ max_tool_steps: int = 10,
51
+ ) -> None:
52
+ self.provider = provider
53
+ self.model = model
54
+ self.tools = tools
55
+ self.max_tool_steps = max_tool_steps
56
+ self.messages: list[Message] = []
57
+ if system_prompt:
58
+ self.messages.append(Message(role="system", content=system_prompt))
59
+ self._validate()
60
+ self._tool_index: dict[str, FunctionTool] = (
61
+ {tool.name: tool for tool in tools} if tools else {}
62
+ )
63
+
64
+ def _validate(self) -> None:
65
+ """Проверить параметры конструктора, чтобы ошибки падали сразу."""
66
+ if self.max_tool_steps < 1:
67
+ raise ValueError(
68
+ f"max_tool_steps должен быть положительным числом, получено: {self.max_tool_steps}"
69
+ )
70
+ if self.tools is not None:
71
+ seen: set[str] = set()
72
+ for tool in self.tools:
73
+ if tool.name in seen:
74
+ raise ValueError(f"Дубликат имени инструмента: {tool.name!r}")
75
+ seen.add(tool.name)
76
+
77
+ def run(self, user_input: str) -> str:
78
+ """Отправить сообщение пользователя и вернуть текстовый ответ.
79
+
80
+ Если у агента заданы инструменты и модель запрашивает их вызов,
81
+ ``run()`` исполняет инструменты и возвращает результат модели в
82
+ диалог до тех пор, пока модель не даст финальный текстовый ответ
83
+ (без ``tool_calls``). Все промежуточные сообщения (assistant с
84
+ вызовами, tool-результаты) попадают в историю.
85
+
86
+ Args:
87
+ user_input: Текст сообщения пользователя.
88
+
89
+ Returns:
90
+ Финальный текст ответа модели.
91
+
92
+ Raises:
93
+ ToolCallLimitError: Если модель не завершила диалог за
94
+ ``max_tool_steps`` раундов исполнения инструментов.
95
+ """
96
+ self.messages.append(Message(role="user", content=user_input))
97
+ tool_steps = 0
98
+ while True:
99
+ response = self.provider.complete(self._request())
100
+ assistant_message = response.message
101
+ self.messages.append(assistant_message)
102
+ if not assistant_message.tool_calls:
103
+ return assistant_message.content
104
+ if tool_steps >= self.max_tool_steps:
105
+ names = ", ".join(call.name for call in assistant_message.tool_calls)
106
+ raise ToolCallLimitError(
107
+ f"Модель не завершила диалог: превышен лимит раундов tool calling "
108
+ f"({self.max_tool_steps}). Модель продолжает запрашивать инструменты "
109
+ f"({names or 'без имён'}) вместо финального текстового ответа."
110
+ )
111
+ self._execute_tool_calls(assistant_message.tool_calls)
112
+ tool_steps += 1
113
+
114
+ def _execute_tool_calls(self, tool_calls: list[ToolCall]) -> None:
115
+ """Исполнить инструменты и добавить результаты в историю как tool-сообщения.
116
+
117
+ Любая ошибка (неизвестное имя, битые аргументы, исключение в функции)
118
+ не роняет диалог, а уходит модели текстом ошибки — модель может
119
+ скорректировать запрос.
120
+ """
121
+ for call in tool_calls:
122
+ tool = self._tool_index.get(call.name)
123
+ if tool is None:
124
+ available = ", ".join(sorted(self._tool_index)) or "нет"
125
+ content = (
126
+ f"Ошибка: инструмент {call.name!r} не найден. "
127
+ f"Доступные инструменты: {available}."
128
+ )
129
+ else:
130
+ try:
131
+ arguments = json.loads(call.arguments or "{}")
132
+ if not isinstance(arguments, dict):
133
+ raise ValueError("аргументы должны быть JSON-объектом")
134
+ content = self._stringify_result(tool.func(**arguments))
135
+ except Exception as exc:
136
+ content = f"Ошибка при вызове {call.name!r}: {exc}"
137
+ self.messages.append(Message(role="tool", content=content, tool_call_id=call.id))
138
+
139
+ @staticmethod
140
+ def _stringify_result(result: Any) -> str:
141
+ """Привести результат функции к строке для tool-сообщения.
142
+
143
+ Структуры сериализуются в JSON (читаемо, без \\u-escape), None означает
144
+ «успех без данных» и превращается в пустую строку.
145
+ """
146
+ if result is None:
147
+ return ""
148
+ if isinstance(result, dict | list):
149
+ return json.dumps(result, ensure_ascii=False, default=str)
150
+ return str(result)
151
+
152
+ def stream_run(self, user_input: str) -> Iterator[str]:
153
+ """Отправить сообщение пользователя и получить ответ потоком.
154
+
155
+ Поведение аналогично ``run``, но ответ возвращается по одному
156
+ фрагменту за раз. По завершении полный ответ попадает в историю.
157
+
158
+ Args:
159
+ user_input: Текст сообщения пользователя.
160
+
161
+ Yields:
162
+ Фрагменты текста ответа модели.
163
+
164
+ Raises:
165
+ ValueError: Если у агента заданы инструменты — потоковый ответ
166
+ не разбирает ``tool_calls``, используйте ``run()``.
167
+ """
168
+ if self.tools:
169
+ raise ValueError(
170
+ "stream_run() не поддерживает инструменты: потоковый ответ провайдера "
171
+ "не разбирает tool_calls. Используйте run() для сценариев с tools."
172
+ )
173
+ self.messages.append(Message(role="user", content=user_input))
174
+ request = ChatRequest(messages=list(self.messages), model=self.model or "", stream=True)
175
+ full_text = ""
176
+ for chunk in self.provider.stream(request):
177
+ full_text += chunk.delta
178
+ yield chunk.delta
179
+ self.messages.append(Message(role="assistant", content=full_text))
180
+
181
+ def reset(self) -> None:
182
+ """Очистить историю диалога, оставив только system-промпт."""
183
+ system = next((m for m in self.messages if m.role == "system"), None)
184
+ self.messages = [system] if system is not None else []
185
+
186
+ def _request(self) -> ChatRequest:
187
+ # Копия списка: запрос не должен разделять состояние с историей агента,
188
+ # иначе последующие append в self.messages мутируют переданный запрос.
189
+ # Пустая model означает «модель провайдера по умолчанию»: адаптеры
190
+ # (например, OpenAIProvider) подставляют свою дефолтную модель.
191
+ return ChatRequest(
192
+ messages=list(self.messages),
193
+ model=self.model or "",
194
+ tools=self.tools,
195
+ )
@@ -0,0 +1,62 @@
1
+ """Провайдеры ember: интерфейс, реестр и встроенные реализации."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from ember.providers.base import Provider, ProviderError
9
+ from ember.providers.mock import MockProvider
10
+ from ember.providers.openai import OpenAIProvider
11
+
12
+ __all__ = [
13
+ "MockProvider",
14
+ "OpenAIProvider",
15
+ "Provider",
16
+ "ProviderError",
17
+ "get_provider",
18
+ "register_provider",
19
+ ]
20
+
21
+ _REGISTRY: dict[str, type[Provider]] = {
22
+ "mock": MockProvider,
23
+ "openai": OpenAIProvider,
24
+ }
25
+
26
+
27
+ def register_provider(name: str) -> Callable[[type[Provider]], type[Provider]]:
28
+ """Декоратор: зарегистрировать класс провайдера в реестре по имени.
29
+
30
+ Args:
31
+ name: Имя, по которому провайдер будет доступен через get_provider().
32
+
33
+ Returns:
34
+ Декоратор, который добавляет класс в реестр и возвращает его без изменений.
35
+ """
36
+
37
+ def decorator(cls: type[Provider]) -> type[Provider]:
38
+ _REGISTRY[name] = cls
39
+ return cls
40
+
41
+ return decorator
42
+
43
+
44
+ def get_provider(name: str, **kwargs: Any) -> Provider:
45
+ """Создать экземпляр провайдера по имени из реестра.
46
+
47
+ Args:
48
+ name: Имя зарегистрированного провайдера (например, "mock").
49
+ **kwargs: Аргументы, передаваемые конструктору провайдера.
50
+
51
+ Returns:
52
+ Новый экземпляр провайдера.
53
+
54
+ Raises:
55
+ ValueError: Если провайдер с таким именем не зарегистрирован.
56
+ """
57
+ try:
58
+ provider_cls = _REGISTRY[name]
59
+ except KeyError:
60
+ available = ", ".join(sorted(_REGISTRY))
61
+ raise ValueError(f"Неизвестный провайдер: {name!r}. Доступны: {available}") from None
62
+ return provider_cls(**kwargs)
@@ -0,0 +1,46 @@
1
+ """Абстрактный интерфейс LLM-провайдера."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Iterator
7
+
8
+ from ember.types import ChatRequest, ChatResponse, StreamChunk
9
+
10
+
11
+ class ProviderError(Exception):
12
+ """Ошибка при обращении к LLM-провайдеру.
13
+
14
+ Адаптеры оборачивают ошибки SDK (сеть, неверный ключ, код ответа)
15
+ в этот тип, чтобы пользователь работал с единым интерфейсом ошибок.
16
+ """
17
+
18
+
19
+ class Provider(ABC):
20
+ """Единый интерфейс для LLM-провайдеров.
21
+
22
+ Каждый адаптер (OpenAI, Anthropic, Gemini, локальные модели) реализует
23
+ методы complete и stream, конвертируя свой формат в модели ядра ember.
24
+ """
25
+
26
+ @abstractmethod
27
+ def complete(self, request: ChatRequest) -> ChatResponse:
28
+ """Получить полный ответ модели на запрос.
29
+
30
+ Args:
31
+ request: Запрос к модели.
32
+
33
+ Returns:
34
+ Полный ответ модели.
35
+ """
36
+
37
+ @abstractmethod
38
+ def stream(self, request: ChatRequest) -> Iterator[StreamChunk]:
39
+ """Получить ответ модели потоком — по одному фрагменту за раз.
40
+
41
+ Args:
42
+ request: Запрос к модели.
43
+
44
+ Returns:
45
+ Итератор фрагментов ответа.
46
+ """
@@ -0,0 +1,53 @@
1
+ """Мок-провайдер — работает без сети и API-ключей."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+
7
+ from ember.providers.base import Provider
8
+ from ember.types import ChatRequest, ChatResponse, Message, StreamChunk, Usage
9
+
10
+
11
+ class MockProvider(Provider):
12
+ """Детерминированный провайдер, возвращающий заранее заданный текст.
13
+
14
+ Полезен для юнит-тестов, примеров и документации: не требует
15
+ API-ключа и доступа к сети.
16
+
17
+ Attributes:
18
+ response_text: Текст, который возвращается на любой запрос.
19
+ model: Имя модели, указываемое в ответе.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ response_text: str = "Привет! Я мок-провайдер.",
25
+ model: str = "mock-1",
26
+ ) -> None:
27
+ self.response_text = response_text
28
+ self.model = model
29
+
30
+ def complete(self, request: ChatRequest) -> ChatResponse:
31
+ """Возвращает фиксированный ответ, игнорируя содержимое запроса."""
32
+ prompt_tokens = sum(len(m.content.split()) for m in request.messages)
33
+ completion_tokens = len(self.response_text.split())
34
+ return ChatResponse(
35
+ message=Message(role="assistant", content=self.response_text),
36
+ model=self.model,
37
+ usage=Usage(
38
+ prompt_tokens=prompt_tokens,
39
+ completion_tokens=completion_tokens,
40
+ total_tokens=prompt_tokens + completion_tokens,
41
+ ),
42
+ )
43
+
44
+ def stream(self, request: ChatRequest) -> Iterator[StreamChunk]:
45
+ """Отдаёт ответ по одному слову за фрагмент."""
46
+ words = self.response_text.split()
47
+ for index, word in enumerate(words):
48
+ last = index == len(words) - 1
49
+ yield StreamChunk(
50
+ delta=word if last else word + " ",
51
+ model=self.model,
52
+ finish_reason="stop" if last else None,
53
+ )
@@ -0,0 +1,191 @@
1
+ """Адаптер OpenAI — поверх официального SDK ``openai``.
2
+
3
+ Модуль импортирует SDK лениво (в конструкторе), чтобы ядро ember оставалось
4
+ лёгким: без установки ``openai`` остальной функционал библиотеки работает.
5
+ Установите пакет: ``pip install "emberio-labs-ember[openai]"``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from typing import TYPE_CHECKING, Any, cast
12
+
13
+ from ember.providers.base import Provider, ProviderError
14
+ from ember.types import ChatRequest, ChatResponse, Message, StreamChunk, Tool, ToolCall, Usage
15
+
16
+ if TYPE_CHECKING:
17
+ from openai import OpenAI, OpenAIError
18
+ from openai.types.chat import ChatCompletionMessageParam
19
+
20
+ _OPENAI_IMPORT_HINT = (
21
+ "OpenAIProvider требует пакет 'openai'. Установите его: "
22
+ "pip install 'emberio-labs-ember[openai]'"
23
+ )
24
+
25
+
26
+ class OpenAIProvider(Provider):
27
+ """Провайдер поверх официального OpenAI SDK.
28
+
29
+ Атрибуты:
30
+ model: Модель по умолчанию, используемая, когда запрос её не задаёт.
31
+
32
+ Args:
33
+ api_key: API-ключ OpenAI (обязательный).
34
+ model: Модель по умолчанию (например, "gpt-4o-mini").
35
+
36
+ Raises:
37
+ ImportError: Если пакет ``openai`` не установлен.
38
+ """
39
+
40
+ _client: OpenAI
41
+ _openai_error: type[OpenAIError]
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: str,
46
+ model: str = "gpt-4o-mini",
47
+ ) -> None:
48
+ try:
49
+ from openai import OpenAI, OpenAIError
50
+ except ImportError as exc:
51
+ raise ImportError(_OPENAI_IMPORT_HINT) from exc
52
+
53
+ self.model = model
54
+ self._client = OpenAI(api_key=api_key)
55
+ self._openai_error = OpenAIError
56
+
57
+ def complete(self, request: ChatRequest) -> ChatResponse:
58
+ """Получить полный ответ модели (не потоковый).
59
+
60
+ Args:
61
+ request: Запрос к модели.
62
+
63
+ Returns:
64
+ Ответ модели с текстом, моделью и счётчиками токенов. Если модель
65
+ решила вызвать инструменты, в ``message.tool_calls`` будет список
66
+ запрошенных вызовов.
67
+
68
+ Raises:
69
+ ProviderError: Если OpenAI API вернул ошибку.
70
+ """
71
+ try:
72
+ completion = self._client.chat.completions.create(
73
+ stream=False, **self._build_params(request)
74
+ )
75
+ except self._openai_error as exc:
76
+ raise ProviderError(f"Ошибка OpenAI API: {exc}") from exc
77
+
78
+ if not completion.choices:
79
+ raise ProviderError("OpenAI вернул пустой список choices")
80
+
81
+ choice = completion.choices[0]
82
+ message = self._message_from_choice(choice)
83
+ usage = completion.usage
84
+ return ChatResponse(
85
+ message=message,
86
+ model=completion.model,
87
+ usage=(
88
+ Usage(
89
+ prompt_tokens=usage.prompt_tokens,
90
+ completion_tokens=usage.completion_tokens,
91
+ total_tokens=usage.total_tokens,
92
+ )
93
+ if usage is not None
94
+ else None
95
+ ),
96
+ )
97
+
98
+ def stream(self, request: ChatRequest) -> Iterator[StreamChunk]:
99
+ """Получить ответ модели потоком — по одному фрагменту за раз.
100
+
101
+ Потоковый режим агрегирует только текст ответа; вызовы инструментов
102
+ (tool calls) в потоке не разбираются — используйте ``complete``.
103
+
104
+ Args:
105
+ request: Запрос к модели.
106
+
107
+ Yields:
108
+ Фрагменты ответа модели.
109
+
110
+ Raises:
111
+ ProviderError: Если OpenAI API вернул ошибку.
112
+ """
113
+ try:
114
+ chunks = self._client.chat.completions.create(
115
+ stream=True, **self._build_params(request)
116
+ )
117
+ except self._openai_error as exc:
118
+ raise ProviderError(f"Ошибка OpenAI API: {exc}") from exc
119
+
120
+ for chunk in chunks:
121
+ if not chunk.choices:
122
+ continue
123
+ choice = chunk.choices[0]
124
+ yield StreamChunk(
125
+ delta=choice.delta.content or "",
126
+ model=chunk.model,
127
+ finish_reason=choice.finish_reason,
128
+ )
129
+
130
+ def _build_params(self, request: ChatRequest) -> dict[str, Any]:
131
+ """Собрать параметры для вызова chat.completions.create (без stream)."""
132
+ params: dict[str, Any] = {
133
+ "model": request.model or self.model,
134
+ "messages": [self._to_openai_message(message) for message in request.messages],
135
+ "temperature": request.temperature,
136
+ }
137
+ if request.max_tokens is not None:
138
+ params["max_tokens"] = request.max_tokens
139
+ if request.tools:
140
+ params["tools"] = [self._to_openai_tool(tool) for tool in request.tools]
141
+ return params
142
+
143
+ @staticmethod
144
+ def _to_openai_message(message: Message) -> ChatCompletionMessageParam:
145
+ """Сконвертировать Message ядра в формат сообщения OpenAI."""
146
+ params: dict[str, Any] = {"role": message.role, "content": message.content}
147
+ if message.name is not None:
148
+ params["name"] = message.name
149
+ if message.tool_calls:
150
+ params["tool_calls"] = [
151
+ {
152
+ "id": tool_call.id,
153
+ "type": "function",
154
+ "function": {
155
+ "name": tool_call.name,
156
+ "arguments": tool_call.arguments,
157
+ },
158
+ }
159
+ for tool_call in message.tool_calls
160
+ ]
161
+ if message.tool_call_id is not None:
162
+ params["tool_call_id"] = message.tool_call_id
163
+ # cast: ChatCompletionMessageParam — union TypedDict'ов с Literal-ролями,
164
+ # а role в ядре — произвольный str, поэтому mypy не может выбрать вариант.
165
+ return cast("ChatCompletionMessageParam", params)
166
+
167
+ @staticmethod
168
+ def _to_openai_tool(tool: Tool) -> dict[str, Any]:
169
+ """Сконвертировать Tool ядра в параметр tools OpenAI."""
170
+ function: dict[str, Any] = {"name": tool.name}
171
+ if tool.description:
172
+ function["description"] = tool.description
173
+ if tool.parameters is not None:
174
+ function["parameters"] = tool.parameters
175
+ return {"type": "function", "function": function}
176
+
177
+ @staticmethod
178
+ def _message_from_choice(choice: Any) -> Message:
179
+ """Собрать Message ядра из choice ответа OpenAI (complete)."""
180
+ content = choice.message.content or ""
181
+ message = Message(role="assistant", content=content)
182
+ if choice.message.tool_calls:
183
+ message.tool_calls = [
184
+ ToolCall(
185
+ id=tool_call.id,
186
+ name=tool_call.function.name,
187
+ arguments=tool_call.function.arguments or "{}",
188
+ )
189
+ for tool_call in choice.message.tool_calls
190
+ ]
191
+ return message
@@ -0,0 +1,183 @@
1
+ """Модели данных ядра ember — общий язык для всех провайдеров.
2
+
3
+ Эти типы не зависят от конкретных SDK: каждый адаптер (OpenAI, Anthropic,
4
+ Gemini, локальные модели) конвертирует свой формат в модели ядра и обратно.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal, get_args
12
+
13
+ Role = Literal["system", "user", "assistant", "tool"]
14
+ """Допустимые роли участников диалога."""
15
+
16
+ _VALID_ROLES: tuple[str, ...] = get_args(Role)
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class Message:
21
+ """Одно сообщение в диалоге.
22
+
23
+ Attributes:
24
+ role: Роль отправителя: system, user, assistant или tool.
25
+ content: Текст сообщения. Для assistant-сообщений с вызовами
26
+ инструментов может быть пустой строкой.
27
+ name: Опциональное имя участника — позволяет модели различать
28
+ нескольких участников с одной ролью (например, нескольких
29
+ пользователей или ассистентов в одном диалоге).
30
+ tool_calls: Запрошенные моделью вызовы инструментов (только для
31
+ роли assistant).
32
+ tool_call_id: Идентификатор вызова инструмента, на который отвечает
33
+ это сообщение (обязателен для роли tool).
34
+ """
35
+
36
+ role: Role
37
+ content: str
38
+ name: str | None = None
39
+ tool_calls: list[ToolCall] | None = None
40
+ tool_call_id: str | None = None
41
+
42
+ def __post_init__(self) -> None:
43
+ if self.role not in _VALID_ROLES:
44
+ raise ValueError(
45
+ f"Недопустимая роль: {self.role!r}. Ожидается одна из: {', '.join(_VALID_ROLES)}"
46
+ )
47
+ if self.role == "tool" and not self.tool_call_id:
48
+ raise ValueError("Сообщение с ролью 'tool' должно содержать tool_call_id")
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class ToolCall:
53
+ """Запрос модели на вызов инструмента.
54
+
55
+ Attributes:
56
+ id: Уникальный идентификатор вызова — по нему сопоставляется
57
+ результат выполнения (см. Message.tool_call_id).
58
+ name: Имя вызываемой функции.
59
+ arguments: Аргументы функции в виде JSON-строки.
60
+ """
61
+
62
+ id: str
63
+ name: str
64
+ arguments: str = "{}"
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class Tool:
69
+ """Описание функции, доступной модели для вызова (tool calling).
70
+
71
+ Attributes:
72
+ name: Имя функции, которое модель укажет в tool_calls.
73
+ description: Описание: для чего функция, какие параметры — помогает
74
+ модели выбрать подходящий инструмент.
75
+ parameters: JSON Schema параметров функции (например,
76
+ ``{"type": "object", "properties": {...}}``).
77
+ """
78
+
79
+ name: str
80
+ description: str = ""
81
+ parameters: dict[str, Any] | None = None
82
+
83
+ def __post_init__(self) -> None:
84
+ if not self.name:
85
+ raise ValueError("Имя инструмента не может быть пустым")
86
+
87
+
88
+ @dataclass(slots=True, kw_only=True)
89
+ class FunctionTool(Tool):
90
+ """Инструмент для агента: описание для модели + локальная функция.
91
+
92
+ Единый источник истины для tool calling: поля ``Tool`` (name,
93
+ description, parameters) уходят модели в ``ChatRequest.tools``, а
94
+ ``func`` агент вызывает с аргументами из ``ToolCall.arguments`` модели.
95
+ Связка схемы и реализации в одном объекте исключает их рассинхрон.
96
+
97
+ Поля keyword-only (включая унаследованные) — инструмент всегда
98
+ собирается по именам полей, позиционные аргументы не поддерживаются.
99
+
100
+ Attributes:
101
+ func: Python-функция, исполняющая инструмент. Вызывается агентом с
102
+ keyword-аргументами, распарсенными из JSON-объекта arguments.
103
+ """
104
+
105
+ func: Callable[..., Any]
106
+
107
+
108
+ @dataclass(slots=True)
109
+ class Usage:
110
+ """Счётчики токенов за один запрос.
111
+
112
+ Attributes:
113
+ prompt_tokens: Токенов потрачено на вход (промпт).
114
+ completion_tokens: Токенов потрачено на ответ.
115
+ total_tokens: Суммарное количество токенов.
116
+ """
117
+
118
+ prompt_tokens: int
119
+ completion_tokens: int
120
+ total_tokens: int
121
+
122
+
123
+ @dataclass(slots=True)
124
+ class ChatRequest:
125
+ """Запрос к LLM-провайдеру.
126
+
127
+ Attributes:
128
+ messages: История диалога. Не может быть пустой.
129
+ model: Имя модели у провайдера (например, "gpt-4o-mini").
130
+ temperature: Креативность ответа, от 0.0 (детерминированно) и выше.
131
+ max_tokens: Максимум токенов в ответе. None — ограничение провайдера.
132
+ stream: Использовать ли потоковый режим ответа.
133
+ tools: Описания функций, которые модель может вызвать. Тип Sequence,
134
+ чтобы можно было передавать список подтипов Tool (например,
135
+ FunctionTool) без конвертации.
136
+ """
137
+
138
+ messages: list[Message]
139
+ model: str
140
+ temperature: float = 1.0
141
+ max_tokens: int | None = None
142
+ stream: bool = False
143
+ tools: Sequence[Tool] | None = None
144
+
145
+ def __post_init__(self) -> None:
146
+ if not self.messages:
147
+ raise ValueError("Список сообщений не может быть пустым")
148
+ if self.temperature < 0:
149
+ raise ValueError("temperature не может быть отрицательной")
150
+ if self.max_tokens is not None and self.max_tokens <= 0:
151
+ raise ValueError("max_tokens должен быть положительным числом")
152
+
153
+
154
+ @dataclass(slots=True)
155
+ class ChatResponse:
156
+ """Ответ модели на ChatRequest.
157
+
158
+ Attributes:
159
+ message: Сообщение с ответом модели (роль assistant). Если модель
160
+ решила вызвать инструменты, заполнено поле message.tool_calls.
161
+ model: Модель, которая сформировала ответ.
162
+ usage: Счётчики токенов, если провайдер их вернул.
163
+ """
164
+
165
+ message: Message
166
+ model: str
167
+ usage: Usage | None = None
168
+
169
+
170
+ @dataclass(slots=True)
171
+ class StreamChunk:
172
+ """Фрагмент потокового ответа модели.
173
+
174
+ Attributes:
175
+ delta: Часть текста ответа, пришедшая в этом фрагменте.
176
+ model: Модель, из которой пришёл фрагмент.
177
+ finish_reason: Причина завершения ("stop", "length", ...) для
178
+ последнего фрагмента, иначе None.
179
+ """
180
+
181
+ delta: str
182
+ model: str
183
+ finish_reason: str | None = None
@@ -0,0 +1,45 @@
1
+ [tool.poetry]
2
+ name = "emberio-labs-ember"
3
+ version = "0.1.0"
4
+ description = "Библиотека для простой интеграции с LLM-провайдерами и создания собственных агентов"
5
+ license = "MIT"
6
+ authors = ["Ember Labs <dev@emberio.labs>"]
7
+ readme = "README.md"
8
+ packages = [{ include = "ember" }]
9
+
10
+ [tool.poetry.urls]
11
+ Homepage = "https://github.com/emberio-labs/ember"
12
+ Repository = "https://github.com/emberio-labs/ember"
13
+
14
+ [tool.poetry.dependencies]
15
+ python = "^3.10"
16
+ openai = { version = "^3.0", optional = true }
17
+
18
+ [tool.poetry.extras]
19
+ openai = ["openai"]
20
+
21
+ [tool.poetry.group.dev.dependencies]
22
+ pytest = "^8.0"
23
+ ruff = "^0.9"
24
+ mypy = "^1.10"
25
+ openai = "^3.0"
26
+
27
+ [build-system]
28
+ requires = ["poetry-core"]
29
+ build-backend = "poetry.core.masonry.api"
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+ target-version = "py310"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ addopts = "-q"
41
+
42
+ [tool.mypy]
43
+ python_version = "3.10"
44
+ files = ["ember"]
45
+ strict = true