ollambda 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.
- ollambda-0.1.0/LICENSE +21 -0
- ollambda-0.1.0/PKG-INFO +83 -0
- ollambda-0.1.0/README.md +68 -0
- ollambda-0.1.0/ollambda/__init__.py +7 -0
- ollambda-0.1.0/ollambda/api/__init__.py +0 -0
- ollambda-0.1.0/ollambda/api/api.py +160 -0
- ollambda-0.1.0/ollambda/app_factory.py +21 -0
- ollambda-0.1.0/ollambda/engine/__init__.py +0 -0
- ollambda-0.1.0/ollambda/engine/chat.py +64 -0
- ollambda-0.1.0/ollambda/engine/llm/__init__.py +0 -0
- ollambda-0.1.0/ollambda/engine/llm/model.py +123 -0
- ollambda-0.1.0/ollambda/engine/llm/providers/__init__.py +0 -0
- ollambda-0.1.0/ollambda/engine/llm/providers/baseprovider.py +46 -0
- ollambda-0.1.0/ollambda/engine/llm/providers/dummy.py +41 -0
- ollambda-0.1.0/ollambda/engine/llm/providers/llamacpp.py +128 -0
- ollambda-0.1.0/ollambda/engine/llm/server_sent_event.py +309 -0
- ollambda-0.1.0/ollambda/engine/messages/__init__.py +32 -0
- ollambda-0.1.0/ollambda/engine/messages/assistant.py +136 -0
- ollambda-0.1.0/ollambda/engine/messages/base.py +124 -0
- ollambda-0.1.0/ollambda/engine/messages/system.py +5 -0
- ollambda-0.1.0/ollambda/engine/messages/tool.py +24 -0
- ollambda-0.1.0/ollambda/engine/messages/usermessage.py +5 -0
- ollambda-0.1.0/ollambda/utils/__init__.py +11 -0
- ollambda-0.1.0/ollambda/utils/get_from_config.py +39 -0
- ollambda-0.1.0/ollambda/utils/get_from_env.py +6 -0
- ollambda-0.1.0/ollambda.egg-info/PKG-INFO +83 -0
- ollambda-0.1.0/ollambda.egg-info/SOURCES.txt +36 -0
- ollambda-0.1.0/ollambda.egg-info/dependency_links.txt +1 -0
- ollambda-0.1.0/ollambda.egg-info/requires.txt +6 -0
- ollambda-0.1.0/ollambda.egg-info/top_level.txt +1 -0
- ollambda-0.1.0/pyproject.toml +25 -0
- ollambda-0.1.0/setup.cfg +4 -0
- ollambda-0.1.0/tests/test_api.py +248 -0
- ollambda-0.1.0/tests/test_get_from_env.py +21 -0
- ollambda-0.1.0/tests/test_model_creation.py +132 -0
- ollambda-0.1.0/tests/test_printing.py +72 -0
- ollambda-0.1.0/tests/test_server_sent_event.py +286 -0
- ollambda-0.1.0/tests/test_tool_call_buffer.py +105 -0
ollambda-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ollambda contributors
|
|
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.
|
ollambda-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ollambda
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: flask>=3.1.3
|
|
9
|
+
Requires-Dist: gunicorn>=26.0.0
|
|
10
|
+
Requires-Dist: pre-commit>=4.6.0
|
|
11
|
+
Requires-Dist: pytest>=9.1.1
|
|
12
|
+
Requires-Dist: pytest-cov>=7.1.0
|
|
13
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# ollambda
|
|
17
|
+
|
|
18
|
+
Simple Flask based API server to proxy ollama calls to a more performant backend (such as llama.cpp)
|
|
19
|
+
|
|
20
|
+
## How to Use
|
|
21
|
+
|
|
22
|
+
### Installation
|
|
23
|
+
|
|
24
|
+
To install ollambda, clone this repository and use `pip install .`
|
|
25
|
+
|
|
26
|
+
#### venv
|
|
27
|
+
|
|
28
|
+
Venv usage is encouraged, the simplest way to manage this is to use [uv](https://docs.astral.sh/uv/):
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
cd ollambda
|
|
32
|
+
uv venv .venv
|
|
33
|
+
source .venv/bin/activate
|
|
34
|
+
uv pip install .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Configuration
|
|
38
|
+
|
|
39
|
+
Currently ollambda proxies requests to an upstream llama.cpp server. The address is configured via environment variables or a YAML config file at `~/.ollambda/config.yaml`:
|
|
40
|
+
|
|
41
|
+
| Setting | Env var | Config key | Default |
|
|
42
|
+
| ---------- | --------------------- | --------------------- | ----------- |
|
|
43
|
+
| Proxy IP | `OLLAMBDA_PROXY_IP` | `ollambda_proxy_ip` | `127.0.0.1` |
|
|
44
|
+
| Proxy port | `OLLAMBDA_PROXY_PORT` | `ollambda_proxy_port` | `8080` |
|
|
45
|
+
|
|
46
|
+
### Running the server
|
|
47
|
+
|
|
48
|
+
Start with Gunicorn (included as a dependency):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
gunicorn -c gunicorn.conf.py ollambda:app
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This spawns one worker per CPU core and binds to `0.0.0.0:5000` by default. Override settings on the command line:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
gunicorn ollambda:app -w 4 -b 0.0.0.0:5000
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## ollama API coverage
|
|
61
|
+
|
|
62
|
+
| Method | Endpoint | Description | Implemented? |
|
|
63
|
+
| ------ | -------------------- | ----------------------------------------------------------- | ------------ |
|
|
64
|
+
| POST | `/api/generate` | Generate a completion (streaming or non-streaming) | N |
|
|
65
|
+
| POST | `/api/chat` | Generate a chat completion (with tool calling support) | Y |
|
|
66
|
+
| POST | `/api/create` | Create a model from another model, GGUF, or safetensors | N (no plans) |
|
|
67
|
+
| GET | `/api/tags` | List locally available models | Y |
|
|
68
|
+
| POST | `/api/show` | Show model information (details, modelfile, template, etc.) | Y |
|
|
69
|
+
| POST | `/api/copy` | Copy a model to a new name | N |
|
|
70
|
+
| DELETE | `/api/delete` | Delete a model | Y |
|
|
71
|
+
| POST | `/api/pull` | Download a model from the Ollama library | N |
|
|
72
|
+
| POST | `/api/push` | Upload a model to a model library | N (no plans) |
|
|
73
|
+
| POST | `/api/embed` | Generate embeddings from a model | Y |
|
|
74
|
+
| GET | `/api/ps` | List models currently loaded in memory | Y |
|
|
75
|
+
| GET | `/api/version` | Retrieve the Ollama server version | Y |
|
|
76
|
+
| HEAD | `/api/blobs/:digest` | Check if a file blob exists on the server | N (no plans) |
|
|
77
|
+
| POST | `/api/blobs/:digest` | Push a file blob to the server | N (no plans) |
|
|
78
|
+
|
|
79
|
+
> [!IMPORTANT]
|
|
80
|
+
> Current "implementation" may not be fully complete and API aligned.
|
|
81
|
+
|
|
82
|
+
> [!NOTE]
|
|
83
|
+
> Source: https://github.com/ollama/ollama/blob/main/docs/api.md
|
ollambda-0.1.0/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# ollambda
|
|
2
|
+
|
|
3
|
+
Simple Flask based API server to proxy ollama calls to a more performant backend (such as llama.cpp)
|
|
4
|
+
|
|
5
|
+
## How to Use
|
|
6
|
+
|
|
7
|
+
### Installation
|
|
8
|
+
|
|
9
|
+
To install ollambda, clone this repository and use `pip install .`
|
|
10
|
+
|
|
11
|
+
#### venv
|
|
12
|
+
|
|
13
|
+
Venv usage is encouraged, the simplest way to manage this is to use [uv](https://docs.astral.sh/uv/):
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
cd ollambda
|
|
17
|
+
uv venv .venv
|
|
18
|
+
source .venv/bin/activate
|
|
19
|
+
uv pip install .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Configuration
|
|
23
|
+
|
|
24
|
+
Currently ollambda proxies requests to an upstream llama.cpp server. The address is configured via environment variables or a YAML config file at `~/.ollambda/config.yaml`:
|
|
25
|
+
|
|
26
|
+
| Setting | Env var | Config key | Default |
|
|
27
|
+
| ---------- | --------------------- | --------------------- | ----------- |
|
|
28
|
+
| Proxy IP | `OLLAMBDA_PROXY_IP` | `ollambda_proxy_ip` | `127.0.0.1` |
|
|
29
|
+
| Proxy port | `OLLAMBDA_PROXY_PORT` | `ollambda_proxy_port` | `8080` |
|
|
30
|
+
|
|
31
|
+
### Running the server
|
|
32
|
+
|
|
33
|
+
Start with Gunicorn (included as a dependency):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
gunicorn -c gunicorn.conf.py ollambda:app
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This spawns one worker per CPU core and binds to `0.0.0.0:5000` by default. Override settings on the command line:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
gunicorn ollambda:app -w 4 -b 0.0.0.0:5000
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## ollama API coverage
|
|
46
|
+
|
|
47
|
+
| Method | Endpoint | Description | Implemented? |
|
|
48
|
+
| ------ | -------------------- | ----------------------------------------------------------- | ------------ |
|
|
49
|
+
| POST | `/api/generate` | Generate a completion (streaming or non-streaming) | N |
|
|
50
|
+
| POST | `/api/chat` | Generate a chat completion (with tool calling support) | Y |
|
|
51
|
+
| POST | `/api/create` | Create a model from another model, GGUF, or safetensors | N (no plans) |
|
|
52
|
+
| GET | `/api/tags` | List locally available models | Y |
|
|
53
|
+
| POST | `/api/show` | Show model information (details, modelfile, template, etc.) | Y |
|
|
54
|
+
| POST | `/api/copy` | Copy a model to a new name | N |
|
|
55
|
+
| DELETE | `/api/delete` | Delete a model | Y |
|
|
56
|
+
| POST | `/api/pull` | Download a model from the Ollama library | N |
|
|
57
|
+
| POST | `/api/push` | Upload a model to a model library | N (no plans) |
|
|
58
|
+
| POST | `/api/embed` | Generate embeddings from a model | Y |
|
|
59
|
+
| GET | `/api/ps` | List models currently loaded in memory | Y |
|
|
60
|
+
| GET | `/api/version` | Retrieve the Ollama server version | Y |
|
|
61
|
+
| HEAD | `/api/blobs/:digest` | Check if a file blob exists on the server | N (no plans) |
|
|
62
|
+
| POST | `/api/blobs/:digest` | Push a file blob to the server | N (no plans) |
|
|
63
|
+
|
|
64
|
+
> [!IMPORTANT]
|
|
65
|
+
> Current "implementation" may not be fully complete and API aligned.
|
|
66
|
+
|
|
67
|
+
> [!NOTE]
|
|
68
|
+
> Source: https://github.com/ollama/ollama/blob/main/docs/api.md
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from flask import Blueprint, Response, abort, jsonify, request
|
|
4
|
+
|
|
5
|
+
from ollambda import __version__
|
|
6
|
+
from ollambda.engine.chat import ChatHandler
|
|
7
|
+
from ollambda.engine.llm.providers.llamacpp import ProviderLlamaCPP
|
|
8
|
+
from ollambda.engine.messages import list_message_factory
|
|
9
|
+
|
|
10
|
+
bp = Blueprint("api", __name__, url_prefix="/api")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
chat_handler = ChatHandler(provider=ProviderLlamaCPP())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_request_body() -> dict:
|
|
17
|
+
"""Try JSON body first, then fall back to form-encoded JSON."""
|
|
18
|
+
data = request.get_json(silent=True)
|
|
19
|
+
if data is not None:
|
|
20
|
+
return data
|
|
21
|
+
|
|
22
|
+
for key, value in request.form.items():
|
|
23
|
+
for candidate in (value, key):
|
|
24
|
+
if candidate.strip().startswith("{"):
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(candidate)
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@bp.route("/chat", methods=("POST",))
|
|
34
|
+
def chat():
|
|
35
|
+
"""Generate the next message in a chat.
|
|
36
|
+
|
|
37
|
+
Parameters:
|
|
38
|
+
model (str): model name
|
|
39
|
+
messages (list[str]): list of messages in the chat
|
|
40
|
+
tools (list[str]): list of tools in json
|
|
41
|
+
think (bool): should the model think before responding?
|
|
42
|
+
format (str): optional json format for response
|
|
43
|
+
stream (bool): return response as a stream of objects if True. Default False.
|
|
44
|
+
|
|
45
|
+
`message` object fields:
|
|
46
|
+
role (str): system, user, assistant or tool
|
|
47
|
+
content (str): message content
|
|
48
|
+
thinking (str): thinking process (for thinking models)
|
|
49
|
+
images (list[str], optional): list of images
|
|
50
|
+
tool_calls (list[str], optional): list of tools in JSON that the model wants to use
|
|
51
|
+
tool_name (str, optional): name of the tool that was executed
|
|
52
|
+
"""
|
|
53
|
+
data = _parse_request_body()
|
|
54
|
+
|
|
55
|
+
# pop the messages, they're very verbose when printing the data
|
|
56
|
+
model = data.get("model", None)
|
|
57
|
+
# handle remaining args/settings
|
|
58
|
+
messages = data.pop("messages", [])
|
|
59
|
+
stream = data.get("stream", False)
|
|
60
|
+
|
|
61
|
+
print("Extra args:")
|
|
62
|
+
print(data)
|
|
63
|
+
|
|
64
|
+
if model is None:
|
|
65
|
+
abort(400, "No model specified")
|
|
66
|
+
|
|
67
|
+
if model not in chat_handler.provider.models:
|
|
68
|
+
abort(400, chat_handler.provider.invalid_model_error(model))
|
|
69
|
+
|
|
70
|
+
tools = data.get("tools", None)
|
|
71
|
+
|
|
72
|
+
messages = list_message_factory(messages)
|
|
73
|
+
if stream:
|
|
74
|
+
return Response(
|
|
75
|
+
chat_handler.generate_response_stream(
|
|
76
|
+
model=model, messages=messages, tools=tools
|
|
77
|
+
),
|
|
78
|
+
mimetype="application/x-ndjson",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
abort(400, "Only streaming responses are currently supported")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@bp.route("/tags", methods=("GET",))
|
|
85
|
+
def tags():
|
|
86
|
+
"""List locally available models."""
|
|
87
|
+
print("Listing models.")
|
|
88
|
+
|
|
89
|
+
models = chat_handler.provider.get_models()
|
|
90
|
+
|
|
91
|
+
return jsonify({"models": [model.info for model in models]})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@bp.route("/show", methods=("POST",))
|
|
95
|
+
def show():
|
|
96
|
+
"""Show model information.
|
|
97
|
+
|
|
98
|
+
Parameters:
|
|
99
|
+
model (str): model name
|
|
100
|
+
verbose (bool, optional): returns full data for verbose response fields if True
|
|
101
|
+
"""
|
|
102
|
+
data = _parse_request_body()
|
|
103
|
+
|
|
104
|
+
if "model" not in data:
|
|
105
|
+
abort(400, "No model specified")
|
|
106
|
+
|
|
107
|
+
return jsonify({})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@bp.route("/delete", methods=("DELETE",))
|
|
111
|
+
def delete():
|
|
112
|
+
"""Delete a model.
|
|
113
|
+
|
|
114
|
+
Parameters:
|
|
115
|
+
model (str): the model name
|
|
116
|
+
"""
|
|
117
|
+
data = _parse_request_body()
|
|
118
|
+
|
|
119
|
+
if "model" not in data:
|
|
120
|
+
abort(400, "No model specified")
|
|
121
|
+
|
|
122
|
+
model = data["model"]
|
|
123
|
+
|
|
124
|
+
abort(404, f"Model {model} not found")
|
|
125
|
+
# should return 200 on successful deletion
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@bp.route("/embed", methods=("POST",))
|
|
129
|
+
def embed():
|
|
130
|
+
"""Generate embeddings.
|
|
131
|
+
|
|
132
|
+
Parameters:
|
|
133
|
+
model: name of the model
|
|
134
|
+
input: text or list of text to generate embeddings for
|
|
135
|
+
"""
|
|
136
|
+
data = _parse_request_body()
|
|
137
|
+
|
|
138
|
+
if "model" not in data:
|
|
139
|
+
abort(400, "No model specified")
|
|
140
|
+
|
|
141
|
+
model = data.get("model")
|
|
142
|
+
|
|
143
|
+
input = data.get("input")
|
|
144
|
+
if isinstance(input, str):
|
|
145
|
+
input = [input]
|
|
146
|
+
|
|
147
|
+
# generate embeddings
|
|
148
|
+
return jsonify({"model": model, "embeddings": ""})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@bp.route("/ps", methods=("GET",))
|
|
152
|
+
def ps():
|
|
153
|
+
"""List running models."""
|
|
154
|
+
return jsonify({"models": []})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@bp.route("/version", methods=("GET",))
|
|
158
|
+
def version():
|
|
159
|
+
"""Respond with version info."""
|
|
160
|
+
return jsonify({"version": __version__})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from flask import Flask
|
|
4
|
+
|
|
5
|
+
from ollambda.api import api
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_app(test_config=None):
|
|
9
|
+
app = Flask(__name__, instance_relative_config=True)
|
|
10
|
+
app.config.from_mapping(SECRET_KEY="test")
|
|
11
|
+
|
|
12
|
+
if test_config is None:
|
|
13
|
+
app.config.from_pyfile("config.py", silent=True)
|
|
14
|
+
else:
|
|
15
|
+
app.config.from_mapping(test_config)
|
|
16
|
+
|
|
17
|
+
os.makedirs(app.instance_path, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
app.register_blueprint(api.bp)
|
|
20
|
+
|
|
21
|
+
return app
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Generator
|
|
3
|
+
|
|
4
|
+
from ollambda.engine.llm.providers.baseprovider import BaseProvider
|
|
5
|
+
from ollambda.engine.messages import AnyMessage
|
|
6
|
+
from ollambda.engine.messages.assistant import AIMessage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ChatHandler:
|
|
10
|
+
"""Wrapper class for a chat instance."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, provider: BaseProvider) -> None:
|
|
13
|
+
self._provider = provider
|
|
14
|
+
|
|
15
|
+
self._messages: list[AnyMessage] = []
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def messages(self) -> list[AnyMessage]:
|
|
19
|
+
return self._messages
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def provider(self) -> BaseProvider:
|
|
23
|
+
return self._provider
|
|
24
|
+
|
|
25
|
+
@provider.setter
|
|
26
|
+
def provider(self, provider: BaseProvider) -> None:
|
|
27
|
+
self._provider = provider
|
|
28
|
+
|
|
29
|
+
def generate_response_stream(
|
|
30
|
+
self,
|
|
31
|
+
model: str,
|
|
32
|
+
messages: list[AnyMessage],
|
|
33
|
+
tools: list[dict[str, str]] | None = None,
|
|
34
|
+
) -> Generator[str]:
|
|
35
|
+
"""Stream response tokens, yielding tokens as they are generated."""
|
|
36
|
+
|
|
37
|
+
print("-" * AIMessage._terminal_line_width)
|
|
38
|
+
print("Generating responses. Input context:")
|
|
39
|
+
print("-" * AIMessage._terminal_line_width)
|
|
40
|
+
for message in messages:
|
|
41
|
+
print(message)
|
|
42
|
+
|
|
43
|
+
print("-" * AIMessage._terminal_line_width)
|
|
44
|
+
print("Response:")
|
|
45
|
+
|
|
46
|
+
response = AIMessage()
|
|
47
|
+
for event in self.provider.generate_stream(
|
|
48
|
+
model=model, messages=messages, tools=tools
|
|
49
|
+
):
|
|
50
|
+
response.add_chunk(event)
|
|
51
|
+
|
|
52
|
+
if event.is_empty:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
print(event.thinking, end="")
|
|
56
|
+
yield json.dumps(event.message_chunk) + "\n"
|
|
57
|
+
|
|
58
|
+
# by convention, once a tool call is complete, the llm stops responding
|
|
59
|
+
if response.tool_calls:
|
|
60
|
+
# extract and yield the json serialisable chunk from the first tool call
|
|
61
|
+
data = response.tool_calls[0].message_chunk
|
|
62
|
+
yield json.dumps(data) + "\n"
|
|
63
|
+
|
|
64
|
+
print(response.pretty_str(show_thinking=False))
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Model:
|
|
6
|
+
"""Represents an LLM instance.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
name (str): "Friendly name" for the model
|
|
10
|
+
model (str): Actual model name
|
|
11
|
+
size (int, optional): Model's size on disk
|
|
12
|
+
digest (str, optional): Model's SHA256 hash
|
|
13
|
+
created_at (timestamp-like, datetime, optional): Model creation time (usually file ctime)
|
|
14
|
+
details (dict, optional): Details field from OpenAI compatible model info dump
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
name: str,
|
|
20
|
+
model: str,
|
|
21
|
+
size: int = -1,
|
|
22
|
+
digest: str | None = None,
|
|
23
|
+
created_at: int | float | datetime | str | None = None,
|
|
24
|
+
details: dict[str, str | int] | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.name = name
|
|
27
|
+
self.model = model
|
|
28
|
+
self.size = size
|
|
29
|
+
self.digest = digest or "sha256:" + "0" * 64
|
|
30
|
+
|
|
31
|
+
self.details = details or {}
|
|
32
|
+
|
|
33
|
+
if created_at is None:
|
|
34
|
+
self._created_at = datetime.now(timezone.utc)
|
|
35
|
+
else:
|
|
36
|
+
self._created_at = Model._parse_datetime(created_at)
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _parse_datetime(t: int | float | str | datetime | None) -> datetime:
|
|
40
|
+
"""Parse a generic-type datetime/timestamp, returning datetime."""
|
|
41
|
+
if isinstance(t, (float, int)):
|
|
42
|
+
return datetime.fromtimestamp(t, tz=timezone.utc)
|
|
43
|
+
elif isinstance(t, datetime):
|
|
44
|
+
return t
|
|
45
|
+
elif t == "":
|
|
46
|
+
return datetime.now(timezone.utc)
|
|
47
|
+
else:
|
|
48
|
+
try:
|
|
49
|
+
return datetime.fromtimestamp(float(t), tz=timezone.utc)
|
|
50
|
+
except ValueError as ex:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"Could not convert created_at of type {type(t)}"
|
|
53
|
+
) from ex
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def created_at(self) -> datetime:
|
|
57
|
+
return self._created_at
|
|
58
|
+
|
|
59
|
+
@created_at.setter
|
|
60
|
+
def created_at(self, t: int | float | datetime) -> None:
|
|
61
|
+
self._created_at = Model._parse_datetime(t)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_llamacpp_models_endpoint(cls, data: dict, metadata: list[dict]) -> "Model":
|
|
65
|
+
"""Construct a Model handler from the /v1/models llama-server endpoint.
|
|
66
|
+
|
|
67
|
+
This endpoint provides two lists:
|
|
68
|
+
models: [
|
|
69
|
+
{"name": "...", ...}
|
|
70
|
+
]
|
|
71
|
+
data: [
|
|
72
|
+
{"id": "..."}
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
We must combine info from these lists.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
data (dict): Info from the `models` section of the response
|
|
79
|
+
metadata (list[dict]): Complete `data` section of the response, for parsing
|
|
80
|
+
"""
|
|
81
|
+
name = data.get("name", None)
|
|
82
|
+
model = data.get("model", None)
|
|
83
|
+
size = data.get("size", -1)
|
|
84
|
+
digest = data.get("digest", None)
|
|
85
|
+
|
|
86
|
+
if not name:
|
|
87
|
+
raise ValueError("No name found in response!")
|
|
88
|
+
|
|
89
|
+
if not model:
|
|
90
|
+
raise ValueError("No model found in response!")
|
|
91
|
+
|
|
92
|
+
new = cls(name=name, model=model, size=size, digest=digest)
|
|
93
|
+
|
|
94
|
+
for meta in metadata:
|
|
95
|
+
if meta.get("id", None) != model:
|
|
96
|
+
continue
|
|
97
|
+
# data field has an "inner" metadata dict
|
|
98
|
+
inner_meta = meta.get("meta", {})
|
|
99
|
+
|
|
100
|
+
created_at = meta.get("created", None)
|
|
101
|
+
if created_at is not None:
|
|
102
|
+
new.created_at = created_at
|
|
103
|
+
|
|
104
|
+
size = inner_meta.get("size", None)
|
|
105
|
+
if size is not None:
|
|
106
|
+
new.size = size
|
|
107
|
+
|
|
108
|
+
return new
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def info(self) -> dict[Any, Any]:
|
|
112
|
+
data = {
|
|
113
|
+
"name": self.name,
|
|
114
|
+
"model": self.model,
|
|
115
|
+
"modified_at": self.created_at.isoformat(timespec="seconds"),
|
|
116
|
+
"size": self.size,
|
|
117
|
+
"digest": self.digest,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if self.details:
|
|
121
|
+
data["details"] = self.details
|
|
122
|
+
|
|
123
|
+
return data
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import Generator
|
|
2
|
+
|
|
3
|
+
from ollambda.engine.llm.model import Model
|
|
4
|
+
from ollambda.engine.llm.server_sent_event import ServerSentEvent
|
|
5
|
+
from ollambda.engine.messages import AnyMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModelNotFoundError(ValueError):
|
|
9
|
+
"""Raised when a requested model is not available."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseProvider:
|
|
15
|
+
"""Allows ollmbda to function as a "proxy", forwarding the request onwards."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._models = None
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def models(self) -> dict[str, Model]:
|
|
22
|
+
if self._models is None:
|
|
23
|
+
self._models = {m.name: m for m in self.get_models()}
|
|
24
|
+
return self._models
|
|
25
|
+
|
|
26
|
+
@models.setter
|
|
27
|
+
def models(self, models: dict[str, Model]) -> None:
|
|
28
|
+
self._models = models
|
|
29
|
+
|
|
30
|
+
def invalid_model_error(self, model: str) -> str:
|
|
31
|
+
"""Return a dynamic error message for an invalid model request."""
|
|
32
|
+
return f"Model {model} is not available. Try: {[m for m in self.models]}"
|
|
33
|
+
|
|
34
|
+
def get_models(self) -> list[Model]:
|
|
35
|
+
"""This method should return a list of available Model objects."""
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def generate_stream(
|
|
39
|
+
self,
|
|
40
|
+
model: str,
|
|
41
|
+
messages: list[AnyMessage],
|
|
42
|
+
tools: list[dict[str, str]] | None = None,
|
|
43
|
+
**kwargs,
|
|
44
|
+
) -> Generator[ServerSentEvent]:
|
|
45
|
+
"""Yield a series of ServerSentEvent objects."""
|
|
46
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Generator
|
|
2
|
+
|
|
3
|
+
from ollambda.engine.llm.model import Model
|
|
4
|
+
from ollambda.engine.llm.providers.baseprovider import BaseProvider
|
|
5
|
+
from ollambda.engine.llm.server_sent_event import ServerSentEvent
|
|
6
|
+
from ollambda.engine.messages import AnyMessage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProviderDummy(BaseProvider):
|
|
10
|
+
"""Dummy provider for testing.
|
|
11
|
+
|
|
12
|
+
Models and stream events are configured directly rather than fetched
|
|
13
|
+
from an upstream server.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
models: list[Model] | None = None,
|
|
19
|
+
events: list[ServerSentEvent] | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
|
|
23
|
+
self._models_list = models or [Model(name="dummy", model="dummy:latest")]
|
|
24
|
+
self._events = events or []
|
|
25
|
+
|
|
26
|
+
def get_models(self) -> list[Model]:
|
|
27
|
+
return self._models_list
|
|
28
|
+
|
|
29
|
+
def generate_stream(
|
|
30
|
+
self,
|
|
31
|
+
model: str,
|
|
32
|
+
messages: list[AnyMessage],
|
|
33
|
+
tools: list[dict[str, str]] | None = None,
|
|
34
|
+
**kwargs,
|
|
35
|
+
) -> Generator[ServerSentEvent]:
|
|
36
|
+
model_obj = self.models.get(model, None)
|
|
37
|
+
if model_obj is None:
|
|
38
|
+
raise ValueError(self.invalid_model_error(model))
|
|
39
|
+
|
|
40
|
+
for event in self._events:
|
|
41
|
+
yield event
|