llm-rapid-mlx 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.
- llm_rapid_mlx-0.1.0/LICENSE +21 -0
- llm_rapid_mlx-0.1.0/PKG-INFO +152 -0
- llm_rapid_mlx-0.1.0/README.md +135 -0
- llm_rapid_mlx-0.1.0/llm_rapid_mlx.egg-info/PKG-INFO +152 -0
- llm_rapid_mlx-0.1.0/llm_rapid_mlx.egg-info/SOURCES.txt +12 -0
- llm_rapid_mlx-0.1.0/llm_rapid_mlx.egg-info/dependency_links.txt +1 -0
- llm_rapid_mlx-0.1.0/llm_rapid_mlx.egg-info/entry_points.txt +2 -0
- llm_rapid_mlx-0.1.0/llm_rapid_mlx.egg-info/requires.txt +6 -0
- llm_rapid_mlx-0.1.0/llm_rapid_mlx.egg-info/top_level.txt +1 -0
- llm_rapid_mlx-0.1.0/llm_rapid_mlx.py +259 -0
- llm_rapid_mlx-0.1.0/pyproject.toml +30 -0
- llm_rapid_mlx-0.1.0/setup.cfg +4 -0
- llm_rapid_mlx-0.1.0/tests/test_live.py +43 -0
- llm_rapid_mlx-0.1.0/tests/test_plugin.py +338 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raullen
|
|
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,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llm-rapid-mlx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run LLM prompts against a local Rapid-MLX server
|
|
5
|
+
Author-email: Raullen <989846+raullenchai@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: llm,rapid-mlx,mlx,local
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: llm<0.38,>=0.37.2
|
|
12
|
+
Requires-Dist: openai<4,>=3
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# llm-rapid-mlx
|
|
19
|
+
|
|
20
|
+
A plugin for [LLM](https://llm.datasette.io/) to use models served by
|
|
21
|
+
[Rapid-MLX](https://github.com/raullenchai/Rapid-MLX), a local inference server
|
|
22
|
+
for Apple Silicon built on MLX. The plugin is a Python HTTP client; only the
|
|
23
|
+
server requires Apple Silicon.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Requires Python 3.11+ and LLM 0.37.2. From this checkout:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
llm install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or install the locally built wheel with `llm install dist/llm_rapid_mlx-0.1.0-py3-none-any.whl`.
|
|
34
|
+
This package has not yet been published to PyPI.
|
|
35
|
+
|
|
36
|
+
Install and start Rapid-MLX separately (the server requires Python 3.12+ if
|
|
37
|
+
installed through pip):
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
brew install rapid-mlx
|
|
41
|
+
rapid-mlx serve qwen3.5-4b-4bit --port 8000
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The plugin neither starts servers nor downloads model weights.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
llm rapid-mlx models
|
|
50
|
+
llm models -q rapid-mlx
|
|
51
|
+
llm -m rapid-mlx/mlx-community/Qwen3.5-4B-MLX-4bit 'say hi'
|
|
52
|
+
llm -m rapid-mlx/mlx-community/Qwen3.5-4B-MLX-4bit -s 'Answer briefly' 'Remember the word pelican'
|
|
53
|
+
llm -c 'What word did I ask you to remember?'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use the IDs shown by discovery. They are always `rapid-mlx/<server-id>`;
|
|
57
|
+
the prefix is removed before sending the request. Streaming is on by default;
|
|
58
|
+
`--no-stream` uses a non-streaming request. Standard LLM conversation logging,
|
|
59
|
+
`llm chat`, options such as `-o temperature 0.5` and `-o max_tokens 128`,
|
|
60
|
+
and Python sync/async models work through LLM's OpenAI adapter.
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
llm -m rapid-mlx/qwen3.5-4b-4bit --functions 'def add(a: int, b: int) -> int: return a + b' 'Use add to calculate 17 + 25'
|
|
64
|
+
llm -m rapid-mlx/qwen3.5-4b-4bit --schema '{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}' 'Say hello in JSON'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Function tools are enabled when discovery advertises `tools`. JSON schemas are
|
|
68
|
+
passed through using OpenAI's `response_format`; successful generation and
|
|
69
|
+
schema enforcement depend on the model and server. Vision models advertising
|
|
70
|
+
`vision` accept PNG, JPEG, WebP and GIF attachments through `-a image.png`.
|
|
71
|
+
Audio, video and PDF attachments are outside this plugin's scope.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
The default endpoint is `http://localhost:8000/v1`.
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
export RAPID_MLX_BASE_URL=http://127.0.0.1:8477/v1
|
|
79
|
+
# Persistent alternative (stores a URL, not a secret):
|
|
80
|
+
llm keys set rapid-mlx-base-url --value http://127.0.0.1:8477/v1
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Endpoint precedence: `-o base_url URL` for an individual chat request, then
|
|
84
|
+
`RAPID_MLX_BASE_URL`, then the `rapid-mlx-base-url` entry in LLM's key store,
|
|
85
|
+
then the default. Discovery and embedding requests use the configured endpoint;
|
|
86
|
+
a chat option alone cannot discover models on a different server. Set the
|
|
87
|
+
environment variable or persistent URL before listing/selecting those models.
|
|
88
|
+
A URL without `/v1` is normalized to include it, including reverse proxy paths.
|
|
89
|
+
|
|
90
|
+
Authentication is optional. For an authenticated server:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
llm keys set rapid-mlx
|
|
94
|
+
# Or: export RAPID_MLX_API_KEY=your-secret
|
|
95
|
+
llm -m rapid-mlx/qwen3.5-4b-4bit --key your-key 'Hello'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Keys follow LLM precedence: Python `model.key`, explicit `--key` (literal or key
|
|
99
|
+
alias), stored `rapid-mlx` key, then `RAPID_MLX_API_KEY`. Discovery uses the stored
|
|
100
|
+
key/environment variable, since model discovery runs before a prompt's `--key`.
|
|
101
|
+
Without a key the client sends the harmless placeholder `not-needed`, accepted
|
|
102
|
+
by Rapid-MLX's default unauthenticated mode. `OPENAI_API_KEY` is never used.
|
|
103
|
+
|
|
104
|
+
## Discovery and embeddings
|
|
105
|
+
|
|
106
|
+
Each registration reads `GET /v1/models` with a two-second timeout and no retries.
|
|
107
|
+
There is no persistent catalogue cache. Restart an interactive LLM session after
|
|
108
|
+
changing the server's model catalogue. Offline, unauthenticated or malformed
|
|
109
|
+
catalogues register no Rapid-MLX models, leaving other plugins usable.
|
|
110
|
+
`llm rapid-mlx models` reports discovery failures explicitly.
|
|
111
|
+
|
|
112
|
+
Models tagged `embedding` are registered only as embedding models. Names are
|
|
113
|
+
never used to guess embedding support. Entries with `text` are chat models;
|
|
114
|
+
older entries without `capabilities` are treated as text-only. Other modalities
|
|
115
|
+
are excluded from chat registration.
|
|
116
|
+
|
|
117
|
+
If the server advertises an embedding model:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
llm embed-models
|
|
121
|
+
llm embed -m rapid-mlx/your-discovered-embedding-model -c 'A coastal bird'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Embeddings use `/v1/embeddings`, text input and batches of up to 32. No embedding
|
|
125
|
+
model is registered when the server only serves chat. Embedding vectors are
|
|
126
|
+
returned in input order, with indexes and finite numeric values checked.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
python3.12 -m venv .venv
|
|
132
|
+
.venv/bin/python -m pip install llm '.[test]' build twine
|
|
133
|
+
.venv/bin/llm install -e .
|
|
134
|
+
.venv/bin/python -m pytest -q
|
|
135
|
+
.venv/bin/python -m build
|
|
136
|
+
.venv/bin/python -m twine check dist/*
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Offline tests use a mocked HTTP server and exercise the real LLM/OpenAI adapters.
|
|
140
|
+
Opt in to a small live smoke test against an existing server:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
RAPID_MLX_LIVE_URL=http://127.0.0.1:8477/v1 .venv/bin/python -m pytest -m live -q
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The optional test does not start or stop a server. Do not set that variable for
|
|
147
|
+
offline tests. The dependency on LLM's bundled OpenAI adapter is constrained to
|
|
148
|
+
0.37.x because that adapter is not a separately versioned public API.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# llm-rapid-mlx
|
|
2
|
+
|
|
3
|
+
A plugin for [LLM](https://llm.datasette.io/) to use models served by
|
|
4
|
+
[Rapid-MLX](https://github.com/raullenchai/Rapid-MLX), a local inference server
|
|
5
|
+
for Apple Silicon built on MLX. The plugin is a Python HTTP client; only the
|
|
6
|
+
server requires Apple Silicon.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Requires Python 3.11+ and LLM 0.37.2. From this checkout:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
llm install -e .
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or install the locally built wheel with `llm install dist/llm_rapid_mlx-0.1.0-py3-none-any.whl`.
|
|
17
|
+
This package has not yet been published to PyPI.
|
|
18
|
+
|
|
19
|
+
Install and start Rapid-MLX separately (the server requires Python 3.12+ if
|
|
20
|
+
installed through pip):
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
brew install rapid-mlx
|
|
24
|
+
rapid-mlx serve qwen3.5-4b-4bit --port 8000
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The plugin neither starts servers nor downloads model weights.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
llm rapid-mlx models
|
|
33
|
+
llm models -q rapid-mlx
|
|
34
|
+
llm -m rapid-mlx/mlx-community/Qwen3.5-4B-MLX-4bit 'say hi'
|
|
35
|
+
llm -m rapid-mlx/mlx-community/Qwen3.5-4B-MLX-4bit -s 'Answer briefly' 'Remember the word pelican'
|
|
36
|
+
llm -c 'What word did I ask you to remember?'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use the IDs shown by discovery. They are always `rapid-mlx/<server-id>`;
|
|
40
|
+
the prefix is removed before sending the request. Streaming is on by default;
|
|
41
|
+
`--no-stream` uses a non-streaming request. Standard LLM conversation logging,
|
|
42
|
+
`llm chat`, options such as `-o temperature 0.5` and `-o max_tokens 128`,
|
|
43
|
+
and Python sync/async models work through LLM's OpenAI adapter.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
llm -m rapid-mlx/qwen3.5-4b-4bit --functions 'def add(a: int, b: int) -> int: return a + b' 'Use add to calculate 17 + 25'
|
|
47
|
+
llm -m rapid-mlx/qwen3.5-4b-4bit --schema '{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}' 'Say hello in JSON'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Function tools are enabled when discovery advertises `tools`. JSON schemas are
|
|
51
|
+
passed through using OpenAI's `response_format`; successful generation and
|
|
52
|
+
schema enforcement depend on the model and server. Vision models advertising
|
|
53
|
+
`vision` accept PNG, JPEG, WebP and GIF attachments through `-a image.png`.
|
|
54
|
+
Audio, video and PDF attachments are outside this plugin's scope.
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
The default endpoint is `http://localhost:8000/v1`.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
export RAPID_MLX_BASE_URL=http://127.0.0.1:8477/v1
|
|
62
|
+
# Persistent alternative (stores a URL, not a secret):
|
|
63
|
+
llm keys set rapid-mlx-base-url --value http://127.0.0.1:8477/v1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Endpoint precedence: `-o base_url URL` for an individual chat request, then
|
|
67
|
+
`RAPID_MLX_BASE_URL`, then the `rapid-mlx-base-url` entry in LLM's key store,
|
|
68
|
+
then the default. Discovery and embedding requests use the configured endpoint;
|
|
69
|
+
a chat option alone cannot discover models on a different server. Set the
|
|
70
|
+
environment variable or persistent URL before listing/selecting those models.
|
|
71
|
+
A URL without `/v1` is normalized to include it, including reverse proxy paths.
|
|
72
|
+
|
|
73
|
+
Authentication is optional. For an authenticated server:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
llm keys set rapid-mlx
|
|
77
|
+
# Or: export RAPID_MLX_API_KEY=your-secret
|
|
78
|
+
llm -m rapid-mlx/qwen3.5-4b-4bit --key your-key 'Hello'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Keys follow LLM precedence: Python `model.key`, explicit `--key` (literal or key
|
|
82
|
+
alias), stored `rapid-mlx` key, then `RAPID_MLX_API_KEY`. Discovery uses the stored
|
|
83
|
+
key/environment variable, since model discovery runs before a prompt's `--key`.
|
|
84
|
+
Without a key the client sends the harmless placeholder `not-needed`, accepted
|
|
85
|
+
by Rapid-MLX's default unauthenticated mode. `OPENAI_API_KEY` is never used.
|
|
86
|
+
|
|
87
|
+
## Discovery and embeddings
|
|
88
|
+
|
|
89
|
+
Each registration reads `GET /v1/models` with a two-second timeout and no retries.
|
|
90
|
+
There is no persistent catalogue cache. Restart an interactive LLM session after
|
|
91
|
+
changing the server's model catalogue. Offline, unauthenticated or malformed
|
|
92
|
+
catalogues register no Rapid-MLX models, leaving other plugins usable.
|
|
93
|
+
`llm rapid-mlx models` reports discovery failures explicitly.
|
|
94
|
+
|
|
95
|
+
Models tagged `embedding` are registered only as embedding models. Names are
|
|
96
|
+
never used to guess embedding support. Entries with `text` are chat models;
|
|
97
|
+
older entries without `capabilities` are treated as text-only. Other modalities
|
|
98
|
+
are excluded from chat registration.
|
|
99
|
+
|
|
100
|
+
If the server advertises an embedding model:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
llm embed-models
|
|
104
|
+
llm embed -m rapid-mlx/your-discovered-embedding-model -c 'A coastal bird'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Embeddings use `/v1/embeddings`, text input and batches of up to 32. No embedding
|
|
108
|
+
model is registered when the server only serves chat. Embedding vectors are
|
|
109
|
+
returned in input order, with indexes and finite numeric values checked.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
python3.12 -m venv .venv
|
|
115
|
+
.venv/bin/python -m pip install llm '.[test]' build twine
|
|
116
|
+
.venv/bin/llm install -e .
|
|
117
|
+
.venv/bin/python -m pytest -q
|
|
118
|
+
.venv/bin/python -m build
|
|
119
|
+
.venv/bin/python -m twine check dist/*
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Offline tests use a mocked HTTP server and exercise the real LLM/OpenAI adapters.
|
|
123
|
+
Opt in to a small live smoke test against an existing server:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
RAPID_MLX_LIVE_URL=http://127.0.0.1:8477/v1 .venv/bin/python -m pytest -m live -q
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The optional test does not start or stop a server. Do not set that variable for
|
|
130
|
+
offline tests. The dependency on LLM's bundled OpenAI adapter is constrained to
|
|
131
|
+
0.37.x because that adapter is not a separately versioned public API.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llm-rapid-mlx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run LLM prompts against a local Rapid-MLX server
|
|
5
|
+
Author-email: Raullen <989846+raullenchai@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: llm,rapid-mlx,mlx,local
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: llm<0.38,>=0.37.2
|
|
12
|
+
Requires-Dist: openai<4,>=3
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# llm-rapid-mlx
|
|
19
|
+
|
|
20
|
+
A plugin for [LLM](https://llm.datasette.io/) to use models served by
|
|
21
|
+
[Rapid-MLX](https://github.com/raullenchai/Rapid-MLX), a local inference server
|
|
22
|
+
for Apple Silicon built on MLX. The plugin is a Python HTTP client; only the
|
|
23
|
+
server requires Apple Silicon.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Requires Python 3.11+ and LLM 0.37.2. From this checkout:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
llm install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or install the locally built wheel with `llm install dist/llm_rapid_mlx-0.1.0-py3-none-any.whl`.
|
|
34
|
+
This package has not yet been published to PyPI.
|
|
35
|
+
|
|
36
|
+
Install and start Rapid-MLX separately (the server requires Python 3.12+ if
|
|
37
|
+
installed through pip):
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
brew install rapid-mlx
|
|
41
|
+
rapid-mlx serve qwen3.5-4b-4bit --port 8000
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The plugin neither starts servers nor downloads model weights.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
llm rapid-mlx models
|
|
50
|
+
llm models -q rapid-mlx
|
|
51
|
+
llm -m rapid-mlx/mlx-community/Qwen3.5-4B-MLX-4bit 'say hi'
|
|
52
|
+
llm -m rapid-mlx/mlx-community/Qwen3.5-4B-MLX-4bit -s 'Answer briefly' 'Remember the word pelican'
|
|
53
|
+
llm -c 'What word did I ask you to remember?'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use the IDs shown by discovery. They are always `rapid-mlx/<server-id>`;
|
|
57
|
+
the prefix is removed before sending the request. Streaming is on by default;
|
|
58
|
+
`--no-stream` uses a non-streaming request. Standard LLM conversation logging,
|
|
59
|
+
`llm chat`, options such as `-o temperature 0.5` and `-o max_tokens 128`,
|
|
60
|
+
and Python sync/async models work through LLM's OpenAI adapter.
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
llm -m rapid-mlx/qwen3.5-4b-4bit --functions 'def add(a: int, b: int) -> int: return a + b' 'Use add to calculate 17 + 25'
|
|
64
|
+
llm -m rapid-mlx/qwen3.5-4b-4bit --schema '{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}' 'Say hello in JSON'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Function tools are enabled when discovery advertises `tools`. JSON schemas are
|
|
68
|
+
passed through using OpenAI's `response_format`; successful generation and
|
|
69
|
+
schema enforcement depend on the model and server. Vision models advertising
|
|
70
|
+
`vision` accept PNG, JPEG, WebP and GIF attachments through `-a image.png`.
|
|
71
|
+
Audio, video and PDF attachments are outside this plugin's scope.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
The default endpoint is `http://localhost:8000/v1`.
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
export RAPID_MLX_BASE_URL=http://127.0.0.1:8477/v1
|
|
79
|
+
# Persistent alternative (stores a URL, not a secret):
|
|
80
|
+
llm keys set rapid-mlx-base-url --value http://127.0.0.1:8477/v1
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Endpoint precedence: `-o base_url URL` for an individual chat request, then
|
|
84
|
+
`RAPID_MLX_BASE_URL`, then the `rapid-mlx-base-url` entry in LLM's key store,
|
|
85
|
+
then the default. Discovery and embedding requests use the configured endpoint;
|
|
86
|
+
a chat option alone cannot discover models on a different server. Set the
|
|
87
|
+
environment variable or persistent URL before listing/selecting those models.
|
|
88
|
+
A URL without `/v1` is normalized to include it, including reverse proxy paths.
|
|
89
|
+
|
|
90
|
+
Authentication is optional. For an authenticated server:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
llm keys set rapid-mlx
|
|
94
|
+
# Or: export RAPID_MLX_API_KEY=your-secret
|
|
95
|
+
llm -m rapid-mlx/qwen3.5-4b-4bit --key your-key 'Hello'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Keys follow LLM precedence: Python `model.key`, explicit `--key` (literal or key
|
|
99
|
+
alias), stored `rapid-mlx` key, then `RAPID_MLX_API_KEY`. Discovery uses the stored
|
|
100
|
+
key/environment variable, since model discovery runs before a prompt's `--key`.
|
|
101
|
+
Without a key the client sends the harmless placeholder `not-needed`, accepted
|
|
102
|
+
by Rapid-MLX's default unauthenticated mode. `OPENAI_API_KEY` is never used.
|
|
103
|
+
|
|
104
|
+
## Discovery and embeddings
|
|
105
|
+
|
|
106
|
+
Each registration reads `GET /v1/models` with a two-second timeout and no retries.
|
|
107
|
+
There is no persistent catalogue cache. Restart an interactive LLM session after
|
|
108
|
+
changing the server's model catalogue. Offline, unauthenticated or malformed
|
|
109
|
+
catalogues register no Rapid-MLX models, leaving other plugins usable.
|
|
110
|
+
`llm rapid-mlx models` reports discovery failures explicitly.
|
|
111
|
+
|
|
112
|
+
Models tagged `embedding` are registered only as embedding models. Names are
|
|
113
|
+
never used to guess embedding support. Entries with `text` are chat models;
|
|
114
|
+
older entries without `capabilities` are treated as text-only. Other modalities
|
|
115
|
+
are excluded from chat registration.
|
|
116
|
+
|
|
117
|
+
If the server advertises an embedding model:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
llm embed-models
|
|
121
|
+
llm embed -m rapid-mlx/your-discovered-embedding-model -c 'A coastal bird'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Embeddings use `/v1/embeddings`, text input and batches of up to 32. No embedding
|
|
125
|
+
model is registered when the server only serves chat. Embedding vectors are
|
|
126
|
+
returned in input order, with indexes and finite numeric values checked.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
python3.12 -m venv .venv
|
|
132
|
+
.venv/bin/python -m pip install llm '.[test]' build twine
|
|
133
|
+
.venv/bin/llm install -e .
|
|
134
|
+
.venv/bin/python -m pytest -q
|
|
135
|
+
.venv/bin/python -m build
|
|
136
|
+
.venv/bin/python -m twine check dist/*
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Offline tests use a mocked HTTP server and exercise the real LLM/OpenAI adapters.
|
|
140
|
+
Opt in to a small live smoke test against an existing server:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
RAPID_MLX_LIVE_URL=http://127.0.0.1:8477/v1 .venv/bin/python -m pytest -m live -q
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The optional test does not start or stop a server. Do not set that variable for
|
|
147
|
+
offline tests. The dependency on LLM's bundled OpenAI adapter is constrained to
|
|
148
|
+
0.37.x because that adapter is not a separately versioned public API.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
llm_rapid_mlx.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
llm_rapid_mlx.egg-info/PKG-INFO
|
|
6
|
+
llm_rapid_mlx.egg-info/SOURCES.txt
|
|
7
|
+
llm_rapid_mlx.egg-info/dependency_links.txt
|
|
8
|
+
llm_rapid_mlx.egg-info/entry_points.txt
|
|
9
|
+
llm_rapid_mlx.egg-info/requires.txt
|
|
10
|
+
llm_rapid_mlx.egg-info/top_level.txt
|
|
11
|
+
tests/test_live.py
|
|
12
|
+
tests/test_plugin.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
llm_rapid_mlx
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""LLM integration for the Rapid-MLX OpenAI-compatible API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import llm
|
|
12
|
+
from llm.default_plugins.openai_models import AsyncChat, Chat
|
|
13
|
+
from openai import AsyncOpenAI, OpenAI, OpenAIError
|
|
14
|
+
from pydantic import Field, field_validator
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "http://localhost:8000/v1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_base_url(value: str) -> str:
|
|
20
|
+
parsed = urlsplit(value)
|
|
21
|
+
try:
|
|
22
|
+
port = parsed.port
|
|
23
|
+
except ValueError:
|
|
24
|
+
raise ValueError("Rapid-MLX base URL has an invalid port") from None
|
|
25
|
+
if (
|
|
26
|
+
parsed.scheme not in {"http", "https"}
|
|
27
|
+
or not parsed.hostname
|
|
28
|
+
or parsed.username is not None
|
|
29
|
+
or parsed.password is not None
|
|
30
|
+
or parsed.query
|
|
31
|
+
or parsed.fragment
|
|
32
|
+
or port == 0
|
|
33
|
+
):
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"Rapid-MLX base URL must be HTTP(S), without credentials, query or fragment"
|
|
36
|
+
)
|
|
37
|
+
value = value.rstrip("/")
|
|
38
|
+
return value if parsed.path.rstrip("/").endswith("/v1") else value + "/v1"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def configured_base_url():
|
|
42
|
+
# Key storage also gives CLI users a persistent endpoint setting.
|
|
43
|
+
return normalize_base_url(
|
|
44
|
+
os.environ.get("RAPID_MLX_BASE_URL")
|
|
45
|
+
or llm.get_key(key_alias="rapid-mlx-base-url")
|
|
46
|
+
or DEFAULT_BASE_URL
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_key(explicit_key=None):
|
|
51
|
+
return llm.get_key(
|
|
52
|
+
explicit_key=explicit_key, key_alias="rapid-mlx", env_var="RAPID_MLX_API_KEY"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def client_kwargs(base_url, key=None, timeout=120):
|
|
57
|
+
# Always provide a key so OPENAI_API_KEY cannot leak to a local endpoint.
|
|
58
|
+
return {
|
|
59
|
+
"base_url": base_url,
|
|
60
|
+
"api_key": key or "not-needed",
|
|
61
|
+
"timeout": timeout,
|
|
62
|
+
"max_retries": 0,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def model_error(error):
|
|
67
|
+
status = getattr(error, "status_code", None)
|
|
68
|
+
detail = f"HTTP {status}" if status else type(error).__name__
|
|
69
|
+
return llm.ModelError(
|
|
70
|
+
f"Rapid-MLX request failed ({detail}); check the server and configuration"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def discover_models(base_url=None):
|
|
75
|
+
"""Fetch a fresh catalogue; never infer embeddings from a model's name."""
|
|
76
|
+
base_url = normalize_base_url(base_url) if base_url else configured_base_url()
|
|
77
|
+
with OpenAI(**client_kwargs(base_url, resolve_key(), timeout=2)) as client:
|
|
78
|
+
records = client.models.list().data
|
|
79
|
+
seen = set()
|
|
80
|
+
result = []
|
|
81
|
+
for record in records:
|
|
82
|
+
if not isinstance(record.id, str) or not record.id or record.id in seen:
|
|
83
|
+
continue
|
|
84
|
+
caps = getattr(record, "capabilities", None)
|
|
85
|
+
if caps is not None and (
|
|
86
|
+
not isinstance(caps, list) or not all(isinstance(c, str) for c in caps)
|
|
87
|
+
):
|
|
88
|
+
continue
|
|
89
|
+
seen.add(record.id)
|
|
90
|
+
# Older servers may omit capabilities: those entries are text only.
|
|
91
|
+
caps = ["text"] if caps is None else caps
|
|
92
|
+
result.append((record.id, caps))
|
|
93
|
+
return base_url, result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _catalogue():
|
|
97
|
+
try:
|
|
98
|
+
return discover_models()
|
|
99
|
+
except (OpenAIError, ValueError, TypeError, AttributeError, OSError):
|
|
100
|
+
return None, []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Options(Chat.Options):
|
|
104
|
+
base_url: str | None = Field(
|
|
105
|
+
default=None, description="Override the Rapid-MLX API base URL for this prompt"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@field_validator("base_url")
|
|
109
|
+
@classmethod
|
|
110
|
+
def validate_base_url(cls, value):
|
|
111
|
+
return normalize_base_url(value) if value is not None else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _RapidMixin:
|
|
115
|
+
needs_key = "rapid-mlx"
|
|
116
|
+
key_env_var = "RAPID_MLX_API_KEY"
|
|
117
|
+
Options = Options
|
|
118
|
+
|
|
119
|
+
def __init__(self, server_id, base_url=None, capabilities=None):
|
|
120
|
+
caps = ["text"] if capabilities is None else capabilities
|
|
121
|
+
super().__init__(
|
|
122
|
+
model_id="rapid-mlx/" + server_id,
|
|
123
|
+
model_name=server_id,
|
|
124
|
+
api_base=base_url or configured_base_url(),
|
|
125
|
+
supports_schema=True,
|
|
126
|
+
supports_tools="tools" in caps,
|
|
127
|
+
vision="vision" in caps,
|
|
128
|
+
)
|
|
129
|
+
# The base also accepts PDFs for OpenAI; Rapid-MLX accepts images here.
|
|
130
|
+
self.attachment_types.discard("application/pdf")
|
|
131
|
+
|
|
132
|
+
def get_key(self, explicit_key=None):
|
|
133
|
+
return self.key if self.key is not None else resolve_key(explicit_key)
|
|
134
|
+
|
|
135
|
+
def get_client(self, key, *, async_=False):
|
|
136
|
+
factory = AsyncOpenAI if async_ else OpenAI
|
|
137
|
+
return factory(**client_kwargs(self.api_base, self.get_key(key)))
|
|
138
|
+
|
|
139
|
+
def build_kwargs(self, prompt, stream):
|
|
140
|
+
kwargs = super().build_kwargs(prompt, stream)
|
|
141
|
+
kwargs.pop("base_url", None)
|
|
142
|
+
return kwargs
|
|
143
|
+
|
|
144
|
+
def for_prompt(self, prompt):
|
|
145
|
+
# An override must not change a shared registered model/conversation.
|
|
146
|
+
model = copy.copy(self)
|
|
147
|
+
model.api_base = prompt.options.base_url or self.api_base
|
|
148
|
+
return model
|
|
149
|
+
|
|
150
|
+
def __str__(self):
|
|
151
|
+
return f"Rapid-MLX: {self.model_id}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class RapidChat(_RapidMixin, Chat):
|
|
155
|
+
def execute(self, prompt, stream, response, conversation=None, key=None):
|
|
156
|
+
model = self.for_prompt(prompt)
|
|
157
|
+
try:
|
|
158
|
+
with model.get_client(key) as client:
|
|
159
|
+
# Let the base adapter own message, stream, usage and tool handling.
|
|
160
|
+
model.get_client = lambda *args, **kwargs: client
|
|
161
|
+
yield from Chat.execute(
|
|
162
|
+
model, prompt, stream, response, conversation, key
|
|
163
|
+
)
|
|
164
|
+
except OpenAIError as ex:
|
|
165
|
+
raise model_error(ex) from None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RapidAsyncChat(_RapidMixin, AsyncChat):
|
|
169
|
+
async def execute(self, prompt, stream, response, conversation=None, key=None):
|
|
170
|
+
model = self.for_prompt(prompt)
|
|
171
|
+
try:
|
|
172
|
+
async with model.get_client(key, async_=True) as client:
|
|
173
|
+
model.get_client = lambda *args, **kwargs: client
|
|
174
|
+
async for chunk in AsyncChat.execute(
|
|
175
|
+
model, prompt, stream, response, conversation, key
|
|
176
|
+
):
|
|
177
|
+
yield chunk
|
|
178
|
+
except OpenAIError as ex:
|
|
179
|
+
raise model_error(ex) from None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class RapidEmbedding(llm.EmbeddingModel):
|
|
183
|
+
needs_key = "rapid-mlx"
|
|
184
|
+
key_env_var = "RAPID_MLX_API_KEY"
|
|
185
|
+
supports_text = True
|
|
186
|
+
supports_binary = False
|
|
187
|
+
batch_size = 32
|
|
188
|
+
|
|
189
|
+
def __init__(self, server_id, base_url=None):
|
|
190
|
+
self.model_id = "rapid-mlx/" + server_id
|
|
191
|
+
self.server_id = server_id
|
|
192
|
+
self.base_url = base_url or configured_base_url()
|
|
193
|
+
|
|
194
|
+
def get_key(self, explicit_key=None):
|
|
195
|
+
return self.key if self.key is not None else resolve_key(explicit_key)
|
|
196
|
+
|
|
197
|
+
def embed_batch(self, items, *, key=None):
|
|
198
|
+
items = list(items)
|
|
199
|
+
if any(not isinstance(item, str) for item in items):
|
|
200
|
+
raise ValueError("Rapid-MLX embeddings accept text strings only")
|
|
201
|
+
if not items:
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
with OpenAI(**client_kwargs(self.base_url, self.get_key(key))) as client:
|
|
205
|
+
data = client.embeddings.create(
|
|
206
|
+
model=self.server_id, input=items, encoding_format="float"
|
|
207
|
+
).data
|
|
208
|
+
except OpenAIError as ex:
|
|
209
|
+
raise model_error(ex) from None
|
|
210
|
+
ordered = sorted(data, key=lambda item: item.index)
|
|
211
|
+
if [item.index for item in ordered] != list(range(len(items))):
|
|
212
|
+
raise llm.ModelError("Rapid-MLX returned invalid embedding indexes")
|
|
213
|
+
vectors = [[float(value) for value in item.embedding] for item in ordered]
|
|
214
|
+
if any(
|
|
215
|
+
not vector or any(not math.isfinite(v) for v in vector)
|
|
216
|
+
for vector in vectors
|
|
217
|
+
):
|
|
218
|
+
raise llm.ModelError("Rapid-MLX returned invalid embedding vectors")
|
|
219
|
+
yield from vectors
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@llm.hookimpl
|
|
223
|
+
def register_models(register):
|
|
224
|
+
base_url, records = _catalogue()
|
|
225
|
+
for server_id, caps in records:
|
|
226
|
+
if "embedding" not in caps and "text" in caps:
|
|
227
|
+
register(
|
|
228
|
+
RapidChat(server_id, base_url, caps),
|
|
229
|
+
RapidAsyncChat(server_id, base_url, caps),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@llm.hookimpl
|
|
234
|
+
def register_embedding_models(register):
|
|
235
|
+
base_url, records = _catalogue()
|
|
236
|
+
for server_id, caps in records:
|
|
237
|
+
if "embedding" in caps:
|
|
238
|
+
register(RapidEmbedding(server_id, base_url))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@llm.hookimpl
|
|
242
|
+
def register_commands(cli):
|
|
243
|
+
@cli.group(name="rapid-mlx")
|
|
244
|
+
def rapid_mlx():
|
|
245
|
+
"Commands for Rapid-MLX."
|
|
246
|
+
|
|
247
|
+
@rapid_mlx.command(name="models")
|
|
248
|
+
def models():
|
|
249
|
+
"Discover server models, reporting connection or authentication errors."
|
|
250
|
+
try:
|
|
251
|
+
_, records = discover_models()
|
|
252
|
+
except (OpenAIError, ValueError, TypeError, AttributeError, OSError) as ex:
|
|
253
|
+
status = getattr(ex, "status_code", None)
|
|
254
|
+
detail = f"HTTP {status}" if status else type(ex).__name__
|
|
255
|
+
raise click.ClickException(
|
|
256
|
+
f"Rapid-MLX discovery failed ({detail}); check RAPID_MLX_BASE_URL and the rapid-mlx key"
|
|
257
|
+
) from None
|
|
258
|
+
for server_id, caps in records:
|
|
259
|
+
click.echo(f"rapid-mlx/{server_id}\t{', '.join(caps)}")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "llm-rapid-mlx"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Run LLM prompts against a local Rapid-MLX server"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{name = "Raullen", email = "989846+raullenchai@users.noreply.github.com"}]
|
|
10
|
+
dependencies = ["llm>=0.37.2,<0.38", "openai>=3,<4"]
|
|
11
|
+
keywords = ["llm", "rapid-mlx", "mlx", "local"]
|
|
12
|
+
|
|
13
|
+
[project.entry-points.llm]
|
|
14
|
+
rapid_mlx = "llm_rapid_mlx"
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
test = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["setuptools>=77"]
|
|
21
|
+
build-backend = "setuptools.build_meta"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
py-modules = ["llm_rapid_mlx"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
asyncio_mode = "auto"
|
|
28
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
29
|
+
markers = ["live: opt-in tests against an existing Rapid-MLX server"]
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Opt-in smoke test; never launches or stops a server."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import llm_rapid_mlx as plugin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.live
|
|
11
|
+
def test_live_stream_and_conversation(monkeypatch):
|
|
12
|
+
url = os.environ.get("RAPID_MLX_LIVE_URL")
|
|
13
|
+
if not url:
|
|
14
|
+
pytest.skip("set RAPID_MLX_LIVE_URL to use an existing server")
|
|
15
|
+
monkeypatch.setenv("RAPID_MLX_BASE_URL", url)
|
|
16
|
+
base_url, records = plugin.discover_models()
|
|
17
|
+
server_id, caps = next((name, caps) for name, caps in records if "text" in caps)
|
|
18
|
+
conversation = plugin.RapidChat(server_id, base_url, caps).conversation()
|
|
19
|
+
response = conversation.prompt(
|
|
20
|
+
"Remember the word pelican. Say hi.", system="Answer briefly.", max_tokens=128
|
|
21
|
+
)
|
|
22
|
+
chunks = list(response)
|
|
23
|
+
assert chunks and response.text().strip()
|
|
24
|
+
assert (
|
|
25
|
+
"pelican"
|
|
26
|
+
in conversation.prompt("What word did I ask you to remember?", max_tokens=128)
|
|
27
|
+
.text()
|
|
28
|
+
.lower()
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.live
|
|
33
|
+
def test_live_embedding_if_advertised(monkeypatch):
|
|
34
|
+
url = os.environ.get("RAPID_MLX_LIVE_URL")
|
|
35
|
+
if not url:
|
|
36
|
+
pytest.skip("set RAPID_MLX_LIVE_URL to use an existing server")
|
|
37
|
+
monkeypatch.setenv("RAPID_MLX_BASE_URL", url)
|
|
38
|
+
base_url, records = plugin.discover_models()
|
|
39
|
+
embedding = next((name for name, caps in records if "embedding" in caps), None)
|
|
40
|
+
if embedding is None:
|
|
41
|
+
pytest.skip("server advertises no embedding model")
|
|
42
|
+
vector = plugin.RapidEmbedding(embedding, base_url).embed("A pelican")
|
|
43
|
+
assert vector and all(isinstance(value, float) for value in vector)
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import llm
|
|
4
|
+
import pytest
|
|
5
|
+
from click.testing import CliRunner
|
|
6
|
+
from llm.cli import cli
|
|
7
|
+
|
|
8
|
+
import llm_rapid_mlx as plugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def completion(text="Hi", tool=None):
|
|
12
|
+
message = {"role": "assistant", "content": text}
|
|
13
|
+
if tool:
|
|
14
|
+
message["tool_calls"] = [tool]
|
|
15
|
+
return {
|
|
16
|
+
"id": "reply",
|
|
17
|
+
"object": "chat.completion",
|
|
18
|
+
"model": "chat",
|
|
19
|
+
"created": 0,
|
|
20
|
+
"choices": [
|
|
21
|
+
{
|
|
22
|
+
"index": 0,
|
|
23
|
+
"message": message,
|
|
24
|
+
"finish_reason": "tool_calls" if tool else "stop",
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def stream(deltas, finish="stop"):
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
"id": "reply",
|
|
35
|
+
"object": "chat.completion.chunk",
|
|
36
|
+
"model": "chat",
|
|
37
|
+
"created": 0,
|
|
38
|
+
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
|
|
39
|
+
}
|
|
40
|
+
for delta in deltas
|
|
41
|
+
] + [
|
|
42
|
+
{
|
|
43
|
+
"id": "reply",
|
|
44
|
+
"object": "chat.completion.chunk",
|
|
45
|
+
"model": "chat",
|
|
46
|
+
"created": 0,
|
|
47
|
+
"choices": [{"index": 0, "delta": {}, "finish_reason": finish}],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "reply",
|
|
51
|
+
"object": "chat.completion.chunk",
|
|
52
|
+
"model": "chat",
|
|
53
|
+
"created": 0,
|
|
54
|
+
"choices": [],
|
|
55
|
+
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_discovery_registration_and_cli(server):
|
|
61
|
+
server.catalogue += [
|
|
62
|
+
{"id": "sounds", "capabilities": ["audio.speech"]},
|
|
63
|
+
{"id": "legacy-embed-in-name"},
|
|
64
|
+
{"id": "vision", "capabilities": ["text", "vision"]},
|
|
65
|
+
{"id": "chat", "capabilities": ["text"]},
|
|
66
|
+
{"id": "invalid", "capabilities": "embedding"},
|
|
67
|
+
]
|
|
68
|
+
registered = []
|
|
69
|
+
plugin.register_models(lambda *models: registered.append(models))
|
|
70
|
+
assert [pair[0].model_id for pair in registered] == [
|
|
71
|
+
"rapid-mlx/chat",
|
|
72
|
+
"rapid-mlx/legacy-embed-in-name",
|
|
73
|
+
"rapid-mlx/vision",
|
|
74
|
+
]
|
|
75
|
+
assert isinstance(registered[0][1], plugin.RapidAsyncChat)
|
|
76
|
+
assert registered[0][0].supports_tools
|
|
77
|
+
assert not registered[1][0].supports_tools
|
|
78
|
+
assert "image/png" in registered[2][0].attachment_types
|
|
79
|
+
assert "application/pdf" not in registered[2][0].attachment_types
|
|
80
|
+
embeddings = []
|
|
81
|
+
plugin.register_embedding_models(embeddings.append)
|
|
82
|
+
assert [model.model_id for model in embeddings] == ["rapid-mlx/embed"]
|
|
83
|
+
result = CliRunner().invoke(cli, ["models", "-q", "rapid-mlx"])
|
|
84
|
+
assert result.exit_code == 0, result.output
|
|
85
|
+
assert "rapid-mlx/chat" in result.output
|
|
86
|
+
assert "rapid-mlx/embed" not in result.output
|
|
87
|
+
assert server.requests[0][0] == "/v1/models"
|
|
88
|
+
assert server.requests[0][1]["authorization"] == "Bearer not-needed"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_no_embedding_without_advertisement(server):
|
|
92
|
+
server.catalogue = [{"id": "embedding-looking-name", "capabilities": ["text"]}]
|
|
93
|
+
registered = []
|
|
94
|
+
plugin.register_embedding_models(registered.append)
|
|
95
|
+
assert registered == []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.parametrize("status", [401, 500])
|
|
99
|
+
def test_discovery_failure_is_nonfatal_and_diagnostic(server, status):
|
|
100
|
+
server.status = status
|
|
101
|
+
registered = []
|
|
102
|
+
plugin.register_models(lambda *models: registered.extend(models))
|
|
103
|
+
plugin.register_embedding_models(registered.append)
|
|
104
|
+
assert registered == []
|
|
105
|
+
result = CliRunner().invoke(cli, ["rapid-mlx", "models"])
|
|
106
|
+
assert result.exit_code == 1
|
|
107
|
+
assert f"HTTP {status}" in result.output
|
|
108
|
+
assert "never-send-this" not in result.output
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_unreachable_and_malformed_discovery(monkeypatch, server):
|
|
112
|
+
monkeypatch.setenv("RAPID_MLX_BASE_URL", "http://127.0.0.1:0")
|
|
113
|
+
assert plugin._catalogue() == (None, [])
|
|
114
|
+
monkeypatch.setenv("RAPID_MLX_BASE_URL", server.url)
|
|
115
|
+
server.catalogue = None
|
|
116
|
+
assert plugin._catalogue() == (None, [])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize(
|
|
120
|
+
"value,expected",
|
|
121
|
+
[
|
|
122
|
+
("http://localhost:8000", "http://localhost:8000/v1"),
|
|
123
|
+
("http://localhost:8000/v1/", "http://localhost:8000/v1"),
|
|
124
|
+
("https://example.org/proxy", "https://example.org/proxy/v1"),
|
|
125
|
+
],
|
|
126
|
+
)
|
|
127
|
+
def test_url_normalization(value, expected):
|
|
128
|
+
assert plugin.normalize_base_url(value) == expected
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.parametrize(
|
|
132
|
+
"value",
|
|
133
|
+
[
|
|
134
|
+
"ftp://host",
|
|
135
|
+
"http://",
|
|
136
|
+
"http://u:p@host",
|
|
137
|
+
"http://host?key=x",
|
|
138
|
+
"http://host#x",
|
|
139
|
+
"http://host:no",
|
|
140
|
+
],
|
|
141
|
+
)
|
|
142
|
+
def test_invalid_urls(value):
|
|
143
|
+
with pytest.raises(ValueError):
|
|
144
|
+
plugin.normalize_base_url(value)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_key_and_endpoint_precedence(server, monkeypatch):
|
|
148
|
+
user_dir = llm.user_dir()
|
|
149
|
+
user_dir.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
(user_dir / "keys.json").write_text(
|
|
151
|
+
json.dumps({"rapid-mlx": "stored", "rapid-mlx-base-url": "http://stored:8000"})
|
|
152
|
+
)
|
|
153
|
+
monkeypatch.setenv("RAPID_MLX_API_KEY", "environment")
|
|
154
|
+
assert plugin.resolve_key() == "stored"
|
|
155
|
+
assert plugin.resolve_key("explicit") == "explicit"
|
|
156
|
+
assert plugin.configured_base_url() == server.url
|
|
157
|
+
monkeypatch.delenv("RAPID_MLX_BASE_URL")
|
|
158
|
+
assert plugin.configured_base_url() == "http://stored:8000/v1"
|
|
159
|
+
(user_dir / "keys.json").unlink()
|
|
160
|
+
assert plugin.resolve_key() == "environment"
|
|
161
|
+
assert plugin.configured_base_url() == plugin.DEFAULT_BASE_URL
|
|
162
|
+
model = plugin.RapidChat("chat", server.url)
|
|
163
|
+
model.key = "python"
|
|
164
|
+
assert model.get_key("explicit") == "python"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_stream_conversation_system_and_usage(server):
|
|
168
|
+
server.responses = [
|
|
169
|
+
stream([{"content": "H"}, {"content": "i"}]),
|
|
170
|
+
completion("pelican"),
|
|
171
|
+
]
|
|
172
|
+
model = plugin.RapidChat("chat")
|
|
173
|
+
conversation = model.conversation()
|
|
174
|
+
first = conversation.prompt("Remember pelican", system="Be brief")
|
|
175
|
+
assert list(first) == ["H", "i"]
|
|
176
|
+
assert first.usage().input == 3
|
|
177
|
+
assert conversation.prompt("What word?", stream=False).text() == "pelican"
|
|
178
|
+
first_body = server.requests[0][2]
|
|
179
|
+
assert first_body["stream"] is True
|
|
180
|
+
assert first_body["model"] == "chat"
|
|
181
|
+
assert first_body["stream_options"] == {"include_usage": True}
|
|
182
|
+
assert server.requests[1][2]["messages"] == [
|
|
183
|
+
{"role": "system", "content": "Be brief"},
|
|
184
|
+
{"role": "user", "content": "Remember pelican"},
|
|
185
|
+
{"role": "assistant", "content": "Hi"},
|
|
186
|
+
{"role": "user", "content": "What word?"},
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_schema_endpoint_option_and_key(server):
|
|
191
|
+
server.responses = [completion('{"greeting":"hi"}')]
|
|
192
|
+
model = plugin.RapidChat("chat", "http://127.0.0.1:1/v1")
|
|
193
|
+
schema = {"type": "object", "properties": {"greeting": {"type": "string"}}}
|
|
194
|
+
assert json.loads(
|
|
195
|
+
model.prompt(
|
|
196
|
+
"Hi",
|
|
197
|
+
schema=schema,
|
|
198
|
+
stream=False,
|
|
199
|
+
base_url=server.url,
|
|
200
|
+
temperature=0.2,
|
|
201
|
+
key="secret",
|
|
202
|
+
).text()
|
|
203
|
+
) == {"greeting": "hi"}
|
|
204
|
+
path, headers, body = server.requests[0]
|
|
205
|
+
assert path == "/v1/chat/completions"
|
|
206
|
+
assert headers["authorization"] == "Bearer secret"
|
|
207
|
+
assert body["response_format"]["json_schema"]["schema"] == schema
|
|
208
|
+
assert body["temperature"] == 0.2
|
|
209
|
+
assert "base_url" not in body
|
|
210
|
+
assert model.api_base == "http://127.0.0.1:1/v1"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@pytest.mark.parametrize("streaming", [False, True])
|
|
214
|
+
def test_tool_loop(server, streaming):
|
|
215
|
+
def add(a: int, b: int) -> int:
|
|
216
|
+
"Add two numbers."
|
|
217
|
+
return a + b
|
|
218
|
+
|
|
219
|
+
call = {
|
|
220
|
+
"id": "call1",
|
|
221
|
+
"type": "function",
|
|
222
|
+
"function": {"name": "add", "arguments": '{"a":17,"b":25}'},
|
|
223
|
+
}
|
|
224
|
+
if streaming:
|
|
225
|
+
server.responses = [
|
|
226
|
+
stream(
|
|
227
|
+
[
|
|
228
|
+
{
|
|
229
|
+
"tool_calls": [
|
|
230
|
+
{
|
|
231
|
+
"index": 0,
|
|
232
|
+
"id": "call1",
|
|
233
|
+
"type": "function",
|
|
234
|
+
"function": {"name": "add", "arguments": '{"a":17,'},
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"tool_calls": [
|
|
240
|
+
{"index": 0, "function": {"arguments": '"b":25}'}}
|
|
241
|
+
]
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
"tool_calls",
|
|
245
|
+
),
|
|
246
|
+
stream([{"content": "42"}]),
|
|
247
|
+
]
|
|
248
|
+
else:
|
|
249
|
+
server.responses = [completion(None, call), completion("42")]
|
|
250
|
+
model = plugin.RapidChat("chat", capabilities=["text", "tools"])
|
|
251
|
+
chain = model.chain("Add 17 and 25", tools=[add], stream=streaming)
|
|
252
|
+
assert chain.text() == "42"
|
|
253
|
+
body = server.requests[1][2]
|
|
254
|
+
assert body["messages"][-1] == {
|
|
255
|
+
"role": "tool",
|
|
256
|
+
"tool_call_id": "call1",
|
|
257
|
+
"content": "42",
|
|
258
|
+
}
|
|
259
|
+
assert body["messages"][-2]["tool_calls"][0]["id"] == "call1"
|
|
260
|
+
assert server.requests[0][2]["tools"][0]["function"]["name"] == "add"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@pytest.mark.parametrize("streaming", [False, True])
|
|
264
|
+
async def test_async(server, streaming):
|
|
265
|
+
server.responses = [
|
|
266
|
+
stream([{"content": "Hello"}]) if streaming else completion("Hello")
|
|
267
|
+
]
|
|
268
|
+
model = plugin.RapidAsyncChat("chat")
|
|
269
|
+
assert await model.prompt("Hi", stream=streaming).text() == "Hello"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_embeddings_order_and_auth(server, monkeypatch):
|
|
273
|
+
monkeypatch.setenv("RAPID_MLX_API_KEY", "embed-key")
|
|
274
|
+
server.responses = [
|
|
275
|
+
{
|
|
276
|
+
"object": "list",
|
|
277
|
+
"model": "embed",
|
|
278
|
+
"data": [
|
|
279
|
+
{"object": "embedding", "index": 1, "embedding": [3, 4]},
|
|
280
|
+
{"object": "embedding", "index": 0, "embedding": [1, 2]},
|
|
281
|
+
],
|
|
282
|
+
"usage": {"prompt_tokens": 2, "total_tokens": 2},
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
model = plugin.RapidEmbedding("embed")
|
|
286
|
+
assert list(model.embed_multi(iter(["one", "two"]))) == [[1.0, 2.0], [3.0, 4.0]]
|
|
287
|
+
path, headers, body = server.requests[0]
|
|
288
|
+
assert path == "/v1/embeddings"
|
|
289
|
+
assert headers["authorization"] == "Bearer embed-key"
|
|
290
|
+
assert body == {
|
|
291
|
+
"model": "embed",
|
|
292
|
+
"input": ["one", "two"],
|
|
293
|
+
"encoding_format": "float",
|
|
294
|
+
}
|
|
295
|
+
with pytest.raises(ValueError, match="text strings"):
|
|
296
|
+
list(model.embed_batch([b"binary"]))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_invalid_embedding_indexes(server):
|
|
300
|
+
server.responses = [
|
|
301
|
+
{
|
|
302
|
+
"object": "list",
|
|
303
|
+
"model": "embed",
|
|
304
|
+
"data": [
|
|
305
|
+
{"object": "embedding", "index": 1, "embedding": [1, 2]},
|
|
306
|
+
],
|
|
307
|
+
}
|
|
308
|
+
]
|
|
309
|
+
with pytest.raises(llm.ModelError, match="indexes"):
|
|
310
|
+
plugin.RapidEmbedding("embed").embed("one")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@pytest.mark.parametrize("kind", ["chat", "async", "embedding"])
|
|
314
|
+
async def test_api_errors_are_actionable_without_echoing_secrets(server, kind):
|
|
315
|
+
server.post_status = 401
|
|
316
|
+
with pytest.raises(llm.ModelError, match="HTTP 401") as error:
|
|
317
|
+
if kind == "chat":
|
|
318
|
+
plugin.RapidChat("chat").prompt(
|
|
319
|
+
"secret-prompt", key="secret-key", stream=False
|
|
320
|
+
).text()
|
|
321
|
+
elif kind == "async":
|
|
322
|
+
await (
|
|
323
|
+
plugin.RapidAsyncChat("chat")
|
|
324
|
+
.prompt("secret-prompt", key="secret-key", stream=False)
|
|
325
|
+
.text()
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
plugin.RapidEmbedding("embed").embed("secret-prompt", key="secret-key")
|
|
329
|
+
assert "secret" not in str(error.value)
|
|
330
|
+
assert len(server.requests) == 1 # no hidden SDK retries
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_discovery_uses_stored_authentication(server):
|
|
334
|
+
(llm.user_dir() / "keys.json").write_text(
|
|
335
|
+
json.dumps({"rapid-mlx": "stored-secret"})
|
|
336
|
+
)
|
|
337
|
+
plugin.discover_models()
|
|
338
|
+
assert server.requests[0][1]["authorization"] == "Bearer stored-secret"
|