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.
- emberio_labs_ember-0.1.0/LICENSE +21 -0
- emberio_labs_ember-0.1.0/PKG-INFO +203 -0
- emberio_labs_ember-0.1.0/README.md +180 -0
- emberio_labs_ember-0.1.0/ember/__init__.py +43 -0
- emberio_labs_ember-0.1.0/ember/agent.py +195 -0
- emberio_labs_ember-0.1.0/ember/providers/__init__.py +62 -0
- emberio_labs_ember-0.1.0/ember/providers/base.py +46 -0
- emberio_labs_ember-0.1.0/ember/providers/mock.py +53 -0
- emberio_labs_ember-0.1.0/ember/providers/openai.py +191 -0
- emberio_labs_ember-0.1.0/ember/types.py +183 -0
- emberio_labs_ember-0.1.0/pyproject.toml +45 -0
|
@@ -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
|