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.
@@ -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,2 @@
1
+ [llm]
2
+ rapid_mlx = llm_rapid_mlx
@@ -0,0 +1,6 @@
1
+ llm<0.38,>=0.37.2
2
+ openai<4,>=3
3
+
4
+ [test]
5
+ pytest>=8
6
+ pytest-asyncio>=0.24
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"