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.
Files changed (38) hide show
  1. ollambda-0.1.0/LICENSE +21 -0
  2. ollambda-0.1.0/PKG-INFO +83 -0
  3. ollambda-0.1.0/README.md +68 -0
  4. ollambda-0.1.0/ollambda/__init__.py +7 -0
  5. ollambda-0.1.0/ollambda/api/__init__.py +0 -0
  6. ollambda-0.1.0/ollambda/api/api.py +160 -0
  7. ollambda-0.1.0/ollambda/app_factory.py +21 -0
  8. ollambda-0.1.0/ollambda/engine/__init__.py +0 -0
  9. ollambda-0.1.0/ollambda/engine/chat.py +64 -0
  10. ollambda-0.1.0/ollambda/engine/llm/__init__.py +0 -0
  11. ollambda-0.1.0/ollambda/engine/llm/model.py +123 -0
  12. ollambda-0.1.0/ollambda/engine/llm/providers/__init__.py +0 -0
  13. ollambda-0.1.0/ollambda/engine/llm/providers/baseprovider.py +46 -0
  14. ollambda-0.1.0/ollambda/engine/llm/providers/dummy.py +41 -0
  15. ollambda-0.1.0/ollambda/engine/llm/providers/llamacpp.py +128 -0
  16. ollambda-0.1.0/ollambda/engine/llm/server_sent_event.py +309 -0
  17. ollambda-0.1.0/ollambda/engine/messages/__init__.py +32 -0
  18. ollambda-0.1.0/ollambda/engine/messages/assistant.py +136 -0
  19. ollambda-0.1.0/ollambda/engine/messages/base.py +124 -0
  20. ollambda-0.1.0/ollambda/engine/messages/system.py +5 -0
  21. ollambda-0.1.0/ollambda/engine/messages/tool.py +24 -0
  22. ollambda-0.1.0/ollambda/engine/messages/usermessage.py +5 -0
  23. ollambda-0.1.0/ollambda/utils/__init__.py +11 -0
  24. ollambda-0.1.0/ollambda/utils/get_from_config.py +39 -0
  25. ollambda-0.1.0/ollambda/utils/get_from_env.py +6 -0
  26. ollambda-0.1.0/ollambda.egg-info/PKG-INFO +83 -0
  27. ollambda-0.1.0/ollambda.egg-info/SOURCES.txt +36 -0
  28. ollambda-0.1.0/ollambda.egg-info/dependency_links.txt +1 -0
  29. ollambda-0.1.0/ollambda.egg-info/requires.txt +6 -0
  30. ollambda-0.1.0/ollambda.egg-info/top_level.txt +1 -0
  31. ollambda-0.1.0/pyproject.toml +25 -0
  32. ollambda-0.1.0/setup.cfg +4 -0
  33. ollambda-0.1.0/tests/test_api.py +248 -0
  34. ollambda-0.1.0/tests/test_get_from_env.py +21 -0
  35. ollambda-0.1.0/tests/test_model_creation.py +132 -0
  36. ollambda-0.1.0/tests/test_printing.py +72 -0
  37. ollambda-0.1.0/tests/test_server_sent_event.py +286 -0
  38. 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.
@@ -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
@@ -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
@@ -0,0 +1,7 @@
1
+ __version__ = "0.0.1"
2
+
3
+ from ollambda.app_factory import create_app
4
+
5
+ app = create_app()
6
+
7
+ __all__ = ["create_app", "app", "__version__"]
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
@@ -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