ruby_llm-providers-lms 0.1.1 → 0.2.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -0
- data/README.md +467 -104
- data/lib/ruby_llm/providers/lms/anthropic_messages.rb +28 -0
- data/lib/ruby_llm/providers/lms/endpoints.rb +18 -0
- data/lib/ruby_llm/providers/lms/model_management.rb +59 -0
- data/lib/ruby_llm/providers/lms/models.rb +44 -12
- data/lib/ruby_llm/providers/lms/native_chat/conversation.rb +93 -0
- data/lib/ruby_llm/providers/lms/native_chat/streaming.rb +67 -0
- data/lib/ruby_llm/providers/lms/native_chat.rb +134 -0
- data/lib/ruby_llm/providers/lms/native_v1.rb +97 -0
- data/lib/ruby_llm/providers/lms/responses.rb +47 -0
- data/lib/ruby_llm/providers/lms/version.rb +15 -0
- data/lib/ruby_llm/providers/lms.rb +51 -1
- data/models.json +69 -454
- metadata +12 -37
- data/.flayignore +0 -1
- data/.github/workflows/ci.yml +0 -29
- data/.github/workflows/gitleaks.yml +0 -22
- data/.github/workflows/release.yml +0 -36
- data/.overcommit.yml +0 -31
- data/.rspec +0 -2
- data/.rubocop.yml +0 -29
- data/Archspec.rb +0 -14
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_handle_a_multi_turn_conversation.yml +0 -92
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_have_a_basic_conversation.yml +0 -45
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_use_tools.yml +0 -129
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_returns_the_raw_response.yml +0 -73
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_supports_streaming_responses.yml +0 -81
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_can_have_a_basic_conversation.yml +0 -73
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_returns_structured_output.yml +0 -73
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_returns_the_raw_response.yml +0 -73
- data/spec/fixtures/vcr_cassettes/rubyllm_embedding_lms_text_embedding_nomic_embed_text_v1_5_embeds_one_text.yml +0 -828
- data/spec/fixtures/vcr_cassettes/rubyllm_embedding_lms_text_embedding_nomic_embed_text_v1_5_embeds_several_texts.yml +0 -2375
- data/spec/ruby_llm/chat_schema_spec.rb +0 -30
- data/spec/ruby_llm/chat_spec.rb +0 -35
- data/spec/ruby_llm/chat_streaming_spec.rb +0 -22
- data/spec/ruby_llm/chat_tools_spec.rb +0 -30
- data/spec/ruby_llm/embedding_spec.rb +0 -49
- data/spec/ruby_llm/image_spec.rb +0 -23
- data/spec/ruby_llm/models_spec.rb +0 -11
- data/spec/ruby_llm/moderation_spec.rb +0 -22
- data/spec/ruby_llm/providers/lms/connection_guard_spec.rb +0 -47
- data/spec/ruby_llm/providers/lms_spec.rb +0 -153
- data/spec/ruby_llm/rerank_spec.rb +0 -23
- data/spec/ruby_llm/speech_spec.rb +0 -25
- data/spec/ruby_llm/video_spec.rb +0 -27
- data/spec/spec_helper.rb +0 -26
- data/spec/support/models.rb +0 -29
- data/spec/support/rubyllm_configuration.rb +0 -14
- data/spec/support/vcr_configuration.rb +0 -24
data/README.md
CHANGED
|
@@ -1,14 +1,48 @@
|
|
|
1
1
|
# ruby_llm-providers-lms
|
|
2
2
|
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> **What a call does here depends on two choices, not one.** LM Studio serves
|
|
5
|
+
> three different API protocols on a single port — OpenAI, Anthropic, and its
|
|
6
|
+
> own native REST API — and runs whatever model you have downloaded behind all
|
|
7
|
+
> of them. The protocol and the model each decide part of the outcome, and they
|
|
8
|
+
> fail in different ways.
|
|
9
|
+
>
|
|
10
|
+
> **The protocol decides what is expressible.** `with_schema` is honored on
|
|
11
|
+
> `:chat_completions` and `:native_v0` and silently ignored on `:responses` and
|
|
12
|
+
> `:anthropic` — same model, same schema, prose where you expected JSON.
|
|
13
|
+
> Reasoning is spelled `none`/`low`/`medium`/`high`/`xhigh` on the OpenAI
|
|
14
|
+
> endpoints and `off`/`low`/`medium`/`high`/`xhigh`/`on` on the native one.
|
|
15
|
+
> That much is consistent for every model, and this gem papers over what it can.
|
|
16
|
+
>
|
|
17
|
+
> **The model decides whether it honors what the protocol accepted**, and no
|
|
18
|
+
> amount of provider code can fix that. LM Studio forwards
|
|
19
|
+
> `tool_choice: "required"` for anything — qwen3 obeys it, gpt-oss ignores it.
|
|
20
|
+
> It constrains generation with a grammar built from your schema, so every
|
|
21
|
+
> model returns *structurally* valid JSON — and gpt-oss returns
|
|
22
|
+
> `{"name":"analysis","age":0}`, which parses cleanly and means nothing.
|
|
23
|
+
> Reasoning support varies per model: some take graded efforts, some only
|
|
24
|
+
> on/off, and some cannot be turned off at all.
|
|
25
|
+
>
|
|
26
|
+
> This is the price of LM Studio's reach. It is all things to all models and
|
|
27
|
+
> master of none, where a frontier lab's hosted model treats consistency as a
|
|
28
|
+
> feature — one vendor, one protocol, one set of guarantees, held steady on
|
|
29
|
+
> their side. Here the matrix is yours to own. Test against the models you
|
|
30
|
+
> actually intend to ship on, rather than assuming a capability carries across
|
|
31
|
+
> either axis.
|
|
32
|
+
>
|
|
33
|
+
> [Which protocol should you use?](#which-protocol-should-you-use) maps the
|
|
34
|
+
> protocol axis. The model matrices in `spec/support/models.rb` record which
|
|
35
|
+
> models were verified to honor what.
|
|
36
|
+
|
|
3
37
|
[RubyLLM](https://rubyllm.com) provider gem for [LM Studio](https://lmstudio.ai) — run
|
|
4
|
-
local models through LM Studio's
|
|
38
|
+
local models through LM Studio's local server (`lms server start`,
|
|
5
39
|
`http://localhost:1234/v1` by default).
|
|
6
40
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
41
|
+
LM Studio speaks three APIs on one port, and this gem covers all three: the
|
|
42
|
+
**OpenAI** API (the default, and the most capable here), the **Anthropic**
|
|
43
|
+
Messages API, and LM Studio's own **native REST** API — which adds inference
|
|
44
|
+
stats, server-stored conversations, and model management. The provider works
|
|
45
|
+
out of the box: no API key, no configuration.
|
|
12
46
|
|
|
13
47
|
## Installation
|
|
14
48
|
|
|
@@ -30,7 +64,7 @@ RubyLLM.configure do |config|
|
|
|
30
64
|
end
|
|
31
65
|
```
|
|
32
66
|
|
|
33
|
-
##
|
|
67
|
+
## Quick start
|
|
34
68
|
|
|
35
69
|
Use the model identifier shown in LM Studio (not an OpenAI model name):
|
|
36
70
|
|
|
@@ -52,77 +86,171 @@ provider = RubyLLM::Provider.resolve!(:lms).new(RubyLLM.config)
|
|
|
52
86
|
provider.list_models.each { |model| puts model.id }
|
|
53
87
|
```
|
|
54
88
|
|
|
55
|
-
|
|
89
|
+
---
|
|
56
90
|
|
|
57
|
-
The
|
|
58
|
-
recorded from the author's machine: LM Studio serves whatever models *you*
|
|
59
|
-
have downloaded, so your catalog will be different. RubyLLM loads the shipped
|
|
60
|
-
file as the provider's model registry so `RubyLLM.models` has something to
|
|
61
|
-
show.
|
|
91
|
+
# The three APIs
|
|
62
92
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
the
|
|
93
|
+
| API | Endpoints | Protocols |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| [OpenAI](#the-openai-api) | `POST /v1/chat/completions`, `POST /v1/responses`, `GET /v1/models`, `POST /v1/embeddings`, `POST /v1/completions` | `:chat_completions` (default), `:responses` |
|
|
96
|
+
| [Anthropic](#the-anthropic-api) | `POST /v1/messages` | `:anthropic` |
|
|
97
|
+
| [Native REST](#the-native-rest-api) | v0: `POST /api/v0/chat/completions`, `GET /api/v0/models`, `POST /api/v0/embeddings`, … <br> v1: `POST /api/v1/chat`, `GET /api/v1/models`, `POST /api/v1/models/load`, `/unload`, `/download` | `:native_v0`, `:native_chat` |
|
|
67
98
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
- **Browsing what your server offers** is `provider.list_models` (see the
|
|
71
|
-
snippet above), which hits the live endpoints every time.
|
|
72
|
-
- **Making `RubyLLM.models` reflect your machine** means registering your own
|
|
73
|
-
catalog file in place of the shipped sample:
|
|
99
|
+
Everything but the default is opt-in. Pick a protocol process-wide, for one
|
|
100
|
+
conversation, or for a single chat:
|
|
74
101
|
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
```ruby
|
|
103
|
+
RubyLLM.configure { |config| config.lms_protocol = :anthropic } # process-wide
|
|
77
104
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
RubyLLM::Provider.register :lms, RubyLLM::Providers::LMS, models: 'my_models.json'
|
|
81
|
-
RubyLLM.models.refresh
|
|
82
|
-
```
|
|
105
|
+
RubyLLM.context { |config| config.lms_protocol = :native_v0 } # one conversation
|
|
106
|
+
.chat(model: 'qwen/qwen3-4b', provider: :lms)
|
|
83
107
|
|
|
84
|
-
|
|
85
|
-
|
|
108
|
+
RubyLLM.chat(model: 'qwen/qwen3-4b', provider: :lms, # one chat
|
|
109
|
+
protocol: :native_chat, assume_model_exists: true)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Which protocol should you use?
|
|
113
|
+
|
|
114
|
+
Stay on the default unless you need something only another column offers.
|
|
115
|
+
|
|
116
|
+
| | `:chat_completions` | `:responses` | `:anthropic` | `:native_v0` | `:native_chat` |
|
|
117
|
+
| --- | --- | --- | --- | --- | --- |
|
|
118
|
+
| Streaming | yes | yes | yes | yes | yes |
|
|
119
|
+
| Client-side tools (`with_tools`) | yes | yes | yes | yes | **no** — raises; use MCP integrations |
|
|
120
|
+
| Structured output (`with_schema`) | yes | **ignored, silently** | **ignored, silently** | yes | **no** — raises |
|
|
121
|
+
| Inference stats | no | no | no | yes, via `raw` | yes, via `tokens` |
|
|
122
|
+
| LM Studio request extras | yes | yes | yes | yes | sampling only — see below |
|
|
123
|
+
| Server-stored conversations | no | no | no | no | yes |
|
|
124
|
+
| Reasoning control | yes | yes | **no** | yes | yes |
|
|
125
|
+
| Reasoning text returned | yes | yes | no | yes | yes |
|
|
126
|
+
|
|
127
|
+
Checked against LM Studio 0.4.24+1 (see [Tested against](#tested-against)).
|
|
128
|
+
Where a protocol ignores something silently rather than raising, the section
|
|
129
|
+
below says so — those are the cases that cost you an afternoon.
|
|
86
130
|
|
|
87
|
-
|
|
131
|
+
"Reasoning control" means `with_thinking` reaches the server. Whether a
|
|
132
|
+
given *model* reasons at all is a separate question — see
|
|
133
|
+
[Reasoning](#reasoning).
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
# The OpenAI API
|
|
138
|
+
|
|
139
|
+
The surface most code already targets, and the one with the fewest surprises
|
|
140
|
+
here: everything RubyLLM can express works.
|
|
141
|
+
|
|
142
|
+
## Chat Completions — `:chat_completions`
|
|
143
|
+
|
|
144
|
+
The default protocol; nothing to configure. LM Studio honors the usual OpenAI
|
|
145
|
+
vocabulary on `POST /v1/chat/completions`: `model`, `messages`, `temperature`,
|
|
146
|
+
`top_p`, `max_tokens`, `stream`, `stop`, `presence_penalty`,
|
|
147
|
+
`frequency_penalty`, `logit_bias`, `seed`, `tools` / `tool_choice`, and
|
|
148
|
+
`response_format` with a `json_schema` (strict structured output). Vision
|
|
149
|
+
models accept image content parts.
|
|
150
|
+
|
|
151
|
+
You reach all of it through RubyLLM's normal API — `with_temperature`,
|
|
152
|
+
`with_max_output_tokens`, `with_tools`, `with_schema`,
|
|
153
|
+
`chat.ask(with: 'image.png')` — nothing LM Studio-specific required.
|
|
154
|
+
|
|
155
|
+
## Reasoning
|
|
156
|
+
|
|
157
|
+
LM Studio honors OpenAI's `reasoning_effort` on `/v1/chat/completions` and
|
|
158
|
+
answers with `reasoning_content` and a `reasoning_tokens` count, which RubyLLM
|
|
159
|
+
reads into `Message#thinking` and `response.tokens.thinking`:
|
|
88
160
|
|
|
89
161
|
```ruby
|
|
90
|
-
RubyLLM.
|
|
162
|
+
chat = RubyLLM.chat(model: 'qwen/qwen3-4b', provider: :lms)
|
|
163
|
+
response = chat.with_thinking(effort: :low).ask('What is 2 + 2?')
|
|
164
|
+
|
|
165
|
+
response.content # => "4"
|
|
166
|
+
response.thinking.text # => "The user is asking a simple arithmetic question..."
|
|
167
|
+
response.tokens.thinking # => 40
|
|
91
168
|
```
|
|
92
169
|
|
|
93
|
-
|
|
170
|
+
`chat.with_thinking` with no arguments, and `chat.with_thinking(false)`, both
|
|
171
|
+
resolve against the model registry: the provider reads each model's
|
|
172
|
+
`capabilities.reasoning` from LM Studio's native listing and records it as
|
|
173
|
+
RubyLLM reasoning options, so RubyLLM knows which efforts the model takes and
|
|
174
|
+
whether it can be switched off at all.
|
|
175
|
+
|
|
176
|
+
The two chat surfaces spell the vocabulary differently, and the provider
|
|
177
|
+
translates between them so your code does not have to:
|
|
178
|
+
|
|
179
|
+
| | Accepted values |
|
|
180
|
+
| --- | --- |
|
|
181
|
+
| `/v1/chat/completions`, `/v1/responses`, `/api/v0/chat/completions` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh` |
|
|
182
|
+
| `/api/v1/chat` (`:native_chat`) | `off`, `low`, `medium`, `high`, `xhigh`, `on` |
|
|
183
|
+
| RubyLLM's registry (what `Model#reasoning_options` reports) | the first row, plus a `:toggle` option where LM Studio offers `on` |
|
|
184
|
+
|
|
185
|
+
A model that reports no way to stop reasoning — gpt-oss lists only
|
|
186
|
+
`low`/`medium`/`high` — makes `with_thinking(false)` raise, which is the
|
|
187
|
+
registry telling you the truth about the model rather than a gap in the
|
|
188
|
+
catalog.
|
|
189
|
+
|
|
190
|
+
Reasoning is a per-model trait, and the surfaces differ in how they treat a
|
|
191
|
+
model that has none. The OpenAI endpoints accept `reasoning_effort` for any
|
|
192
|
+
model and simply ignore it; `POST /api/v1/chat` rejects the request outright:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
Model 'bible-study-phi3-mini' does not expose reasoning configuration.
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
So on `:native_chat`, only ask for reasoning from a model whose listing
|
|
199
|
+
reports it. `with_thinking` and `with_thinking(false)` already refuse
|
|
200
|
+
client-side for a model in the registry that reports none; an explicit
|
|
201
|
+
`with_thinking(effort:)`, or a model id not in your catalog, reaches the
|
|
202
|
+
server and gets the error above.
|
|
203
|
+
|
|
204
|
+
Which efforts you can ask for depends on what you have downloaded:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
model = RubyLLM.models.find('qwen/qwen3-4b')
|
|
208
|
+
model.supports?(:reasoning) # => true
|
|
209
|
+
model.reasoning_option_values(:effort) # => ["none", "low", "medium", "xhigh"]
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
That information comes from the native listing, so it is only in
|
|
213
|
+
`RubyLLM.models` for models present in the catalog you registered — see
|
|
214
|
+
[The packaged catalog](#the-packaged-catalog-modelsjson).
|
|
215
|
+
|
|
216
|
+
## Responses — `:responses`
|
|
217
|
+
|
|
218
|
+
LM Studio also serves OpenAI's newer Responses API at `POST /v1/responses`,
|
|
219
|
+
and RubyLLM's stock Responses protocol drives it:
|
|
94
220
|
|
|
95
221
|
```ruby
|
|
96
222
|
RubyLLM.configure { |config| config.lms_protocol = :responses }
|
|
97
223
|
```
|
|
98
224
|
|
|
99
|
-
|
|
225
|
+
Streaming, multi-turn conversations and client-side tools all work; the live
|
|
226
|
+
specs in `spec/ruby_llm/chat_responses_spec.rb` cover them.
|
|
100
227
|
|
|
101
|
-
|
|
102
|
-
|
|
228
|
+
**`with_schema` does not work here** — LM Studio ignores the schema on this
|
|
229
|
+
endpoint and nothing raises, so you get unconstrained prose where you expected
|
|
230
|
+
JSON. Use `:chat_completions` for structured output.
|
|
103
231
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
232
|
+
`with_thinking` works here, reasoning text included. It needs this gem's own
|
|
233
|
+
Responses subclass to do so: OpenAI never returns raw reasoning, only an
|
|
234
|
+
optional summary, so RubyLLM's stock protocol reads a reasoning item's
|
|
235
|
+
`summary`. LM Studio runs the model locally and has nothing to hide, so it
|
|
236
|
+
returns the reasoning itself in `content` as `reasoning_text` (and streams it
|
|
237
|
+
as `response.reasoning_text.delta`). The provider reads both shapes.
|
|
109
238
|
|
|
110
|
-
|
|
239
|
+
## Embeddings
|
|
111
240
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
`seed`, `tools` / `tool_choice`, and `response_format` with a `json_schema`
|
|
116
|
-
(strict structured output). Vision models accept image content parts. You
|
|
117
|
-
reach all of these through RubyLLM's normal API — `with_temperature`,
|
|
118
|
-
`with_max_output_tokens`, `with_tools`, `with_schema`, `chat.ask(with: 'image.png')`,
|
|
119
|
-
and so on; nothing LM Studio-specific is required.
|
|
241
|
+
Embeddings go to `POST /v1/embeddings` regardless of which chat protocol is
|
|
242
|
+
in effect — the provider routes them to the OpenAI-compatible endpoint itself,
|
|
243
|
+
because that is the only place LM Studio serves them:
|
|
120
244
|
|
|
121
|
-
|
|
245
|
+
```ruby
|
|
246
|
+
RubyLLM.embed('Hello world', model: 'text-embedding-nomic-embed-text-v1.5', provider: :lms)
|
|
247
|
+
```
|
|
122
248
|
|
|
123
|
-
LM Studio
|
|
124
|
-
|
|
125
|
-
|
|
249
|
+
## LM Studio request extras
|
|
250
|
+
|
|
251
|
+
LM Studio accepts request fields that are not part of OpenAI's vocabulary.
|
|
252
|
+
RubyLLM merges `with_provider_options` into the payload as-is, so these work
|
|
253
|
+
on every protocol except `:native_chat`, whose server rejects unknown keys:
|
|
126
254
|
|
|
127
255
|
| Option | What it does |
|
|
128
256
|
| --- | --- |
|
|
@@ -144,52 +272,268 @@ chat.with_provider_options(
|
|
|
144
272
|
chat.ask('Hello')
|
|
145
273
|
```
|
|
146
274
|
|
|
147
|
-
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
# The Anthropic API
|
|
278
|
+
|
|
279
|
+
LM Studio serves Anthropic's Messages API at `POST /v1/messages`, so a
|
|
280
|
+
codebase written against Claude can point at a local model without changing
|
|
281
|
+
how it talks:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
RubyLLM.configure { |config| config.lms_protocol = :anthropic }
|
|
285
|
+
|
|
286
|
+
chat = RubyLLM.chat(model: 'qwen/qwen3-4b', provider: :lms)
|
|
287
|
+
chat.ask('Hello').raw.body
|
|
288
|
+
# => {"id" => "msg_...", "type" => "message", "role" => "assistant",
|
|
289
|
+
# "content" => [{"type" => "text", "text" => "..."}],
|
|
290
|
+
# "stop_reason" => "end_turn",
|
|
291
|
+
# "usage" => {"input_tokens" => 18, "output_tokens" => 25, ...}}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
RubyLLM's stock Anthropic protocol drives it unchanged apart from the request
|
|
295
|
+
path. LM Studio wants no credentials and no `anthropic-version` header.
|
|
296
|
+
|
|
297
|
+
Plain exchanges, streaming (proper `message_start` / `content_block_delta`
|
|
298
|
+
SSE events) and tool calls (`tool_use` blocks with `stop_reason: "tool_use"`,
|
|
299
|
+
executed as ordinary `RubyLLM::Tool` objects in your process) all work; the
|
|
300
|
+
live specs in `spec/ruby_llm/chat_anthropic_spec.rb` cover all three.
|
|
301
|
+
|
|
302
|
+
**`with_schema` does not work here, and fails quietly.** LM Studio ignores the
|
|
303
|
+
schema and nothing raises. The same model and schema, two protocols:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
# :chat_completions => {"name": "John", "age": 30}
|
|
307
|
+
# :anthropic => "Understood. John is 30 years old. How can I help..."
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Use `:chat_completions` for structured output.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
# The native REST API
|
|
315
|
+
|
|
316
|
+
LM Studio's own API, in two generations. **v0** mirrors the OpenAI shapes and
|
|
317
|
+
adds telemetry; **v1** is a different design, with server-stored conversations
|
|
318
|
+
and model management. The gem uses both.
|
|
319
|
+
|
|
320
|
+
One path gotcha: model management lives under `/api/v1/models/`, not directly
|
|
321
|
+
under `/api/v1/` — it is `POST /api/v1/models/load`, not `POST /api/v1/load`.
|
|
322
|
+
On 0.4.24+1 there is no `download-status` endpoint under either spelling.
|
|
323
|
+
|
|
324
|
+
## Inference stats — `:native_v0`
|
|
325
|
+
|
|
326
|
+
`POST /api/v0/chat/completions` is a strict superset of
|
|
327
|
+
`/v1/chat/completions`: the same request — streaming, `with_tools`,
|
|
328
|
+
`with_schema` and the request extras all included — and the same `choices`
|
|
329
|
+
and `usage`, but it fills in the `stats` block that the OpenAI endpoint
|
|
330
|
+
returns empty, and adds `model_info` and `runtime`.
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
RubyLLM.configure { |config| config.lms_protocol = :native_v0 }
|
|
334
|
+
|
|
335
|
+
response = RubyLLM.chat(model: 'qwen/qwen3-4b', provider: :lms).ask('Hello')
|
|
336
|
+
response.raw.body['stats']
|
|
337
|
+
# => {"tokens_per_second" => 115.19, "time_to_first_token" => 0.0157,
|
|
338
|
+
# "generation_time" => 0.172, "stop_reason" => "eosFound"}
|
|
339
|
+
response.raw.body['runtime']['name'] # => "llama.cpp-mac-arm64-apple-metal-advsimd"
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
RubyLLM has no field for any of this, so it arrives through `Message#raw` —
|
|
343
|
+
unlike `:native_chat`, which folds its stats into `response.tokens`.
|
|
344
|
+
Everything else behaves identically: this is the default protocol pointed at a
|
|
345
|
+
different URL. Reach for it when you want telemetry **and** the full Chat
|
|
346
|
+
Completions feature set.
|
|
347
|
+
|
|
348
|
+
`/v1/chat/completions` does return a `stats` key of its own, but it is `{}` on
|
|
349
|
+
an ordinary request; it carries only `total_draft_tokens_count` and its
|
|
350
|
+
accepted/rejected counterparts, and only when the model was loaded with a
|
|
351
|
+
`draft_model` for speculative decoding. The timing figures, `model_info` and
|
|
352
|
+
`runtime` are v0-only.
|
|
353
|
+
|
|
354
|
+
## Stateful chat — `:native_chat`
|
|
355
|
+
|
|
356
|
+
`POST /api/v1/chat` offers capabilities the OpenAI surface cannot express:
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
chat = RubyLLM.chat(model: 'qwen/qwen3-4b', provider: :lms,
|
|
360
|
+
protocol: :native_chat, assume_model_exists: true)
|
|
361
|
+
response = chat.ask('Hello')
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
- **Stateful conversations** — LM Studio stores each response server-side and
|
|
365
|
+
continues from a `response_id` instead of replaying history. Ordinary
|
|
366
|
+
RubyLLM multi-turn chats just work: each request sends only the new messages
|
|
367
|
+
and points `previous_response_id` at the last stored response. The id is on
|
|
368
|
+
`response.raw.body['response_id']`, and also on `response.raw_content`,
|
|
369
|
+
which RubyLLM persists — so a chat reloaded from the database (Rails
|
|
370
|
+
`acts_as_chat`) continues where it left off rather than refusing to replay.
|
|
371
|
+
- **Performance stats** — `response.raw.body['stats']` carries tokens per
|
|
372
|
+
second, time to first token, and token counts; `response.tokens` (input,
|
|
373
|
+
output, thinking) is filled from it.
|
|
374
|
+
- **Reasoning control** — `chat.with_thinking` maps onto the native
|
|
375
|
+
`reasoning` setting (`off` / `low` / `medium` / `high` / `xhigh` / `on`;
|
|
376
|
+
models accept a subset — gpt-oss takes efforts, most others just on/off).
|
|
377
|
+
RubyLLM's `:none` effort is translated to LM Studio's `off` on the way out.
|
|
378
|
+
Reasoning output comes back separated as `response.thinking.text`, streamed
|
|
379
|
+
as thinking chunks. See [Reasoning](#reasoning).
|
|
380
|
+
- **Server-side MCP tools** — pass `with_provider_options(integrations: [...])`
|
|
381
|
+
to have the server itself run MCP plugins from `mcp.json`
|
|
382
|
+
(`{ id: 'mcp/playwright' }`) or ephemeral MCP servers declared in the
|
|
383
|
+
request (`server_label` / `server_url`), with optional `allowed_tools`
|
|
384
|
+
filtering. Executed tool calls appear in `response.raw.body['output']`.
|
|
385
|
+
- **Per-request context length** — `with_provider_options(context_length: 8192)`.
|
|
386
|
+
- **Opting out of storage** — `with_provider_options(store: false)` keeps the
|
|
387
|
+
conversation off the server, at the cost of multi-turn continuity.
|
|
388
|
+
|
|
389
|
+
- **Sampling settings** — `top_p`, `top_k`, `min_p` and `repeat_penalty` are
|
|
390
|
+
part of the native request schema, so `with_provider_options(top_k: 40)` and
|
|
391
|
+
friends work here too.
|
|
392
|
+
|
|
393
|
+
Limitations: no client-side tools (`with_tools` raises — use
|
|
394
|
+
`:chat_completions`, or MCP integrations), no structured output
|
|
395
|
+
(`with_schema` raises), and image input only as `data_url` parts. Unknown
|
|
396
|
+
request keys are rejected, so the request extras that are *not* in the native
|
|
397
|
+
schema (`ttl`, `draft_model`) raise a `BadRequestError` here — unlike the
|
|
398
|
+
sampling settings above, which the schema does accept.
|
|
399
|
+
|
|
400
|
+
The endpoint reports no stop reason of its own. When a generation runs into
|
|
401
|
+
the `max_output_tokens` the request asked for, the provider reports
|
|
402
|
+
`finish_reason: :length` from the token counts rather than claiming the model
|
|
403
|
+
stopped on its own.
|
|
404
|
+
|
|
405
|
+
## Model management
|
|
406
|
+
|
|
407
|
+
The provider exposes LM Studio's native model-management endpoints, so you can
|
|
408
|
+
control what is in memory without shelling out to `lms`:
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
provider = RubyLLM::Provider.resolve!(:lms).new(RubyLLM.config)
|
|
412
|
+
|
|
413
|
+
provider.native_models # every downloaded model, with architecture,
|
|
414
|
+
# quantization, capabilities, loaded_instances, ...
|
|
415
|
+
provider.loaded_models # just the ones resident in memory
|
|
416
|
+
|
|
417
|
+
instance = provider.load_model('qwen/qwen3-4b', context_length: 8192)
|
|
418
|
+
# => { "instance_id" => "qwen/qwen3-4b", "status" => "loaded", ... }
|
|
419
|
+
provider.unload_model(instance['instance_id'])
|
|
420
|
+
|
|
421
|
+
provider.download_model('qwen/qwen3-4b') # fetch a new model onto disk
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
`load_model` returns the `instance_id` that `unload_model` takes; loading a
|
|
425
|
+
model that is already resident starts a second instance (`...:2`), so hold on
|
|
426
|
+
to the id you were given. For the same reason these calls are sent
|
|
427
|
+
non-idempotent: RubyLLM retries a POST that times out, and a retried load of a
|
|
428
|
+
large model would hold the weights in memory twice. Note that `ttl` is not a load option on the native
|
|
429
|
+
API — set idle TTL per request through the OpenAI endpoints (see
|
|
430
|
+
[LM Studio request extras](#lm-studio-request-extras)) or `lms load --ttl`.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
# Models on your machine
|
|
435
|
+
|
|
436
|
+
## What listing enrichment adds
|
|
437
|
+
|
|
438
|
+
`provider.list_models` starts from the OpenAI-compatible `/v1/models` listing,
|
|
439
|
+
which carries little beyond an id, and merges in LM Studio's native model
|
|
440
|
+
listing. It tries `GET /api/v1/models` first and falls back to
|
|
441
|
+
`GET /api/v0/models`; when neither answers — an older build, or the native API
|
|
442
|
+
turned off — the plain listing is still returned.
|
|
443
|
+
|
|
444
|
+
`Model#name` becomes the human-readable display name where one is reported
|
|
445
|
+
(`"Qwen3 27B"` rather than `"qwen/qwen3.8-27b"`), and `Model#metadata` picks
|
|
446
|
+
up whatever the server knows:
|
|
447
|
+
|
|
448
|
+
| Key | From | Example |
|
|
449
|
+
| --- | --- | --- |
|
|
450
|
+
| `publisher`, `arch`, `quantization`, `compatibility_type`, `state` | v0 and v1 | `"qwen"`, `"qwen35"`, `"Q4_K_M"`, `"gguf"`, `"loaded"` |
|
|
451
|
+
| `display_name`, `description` | v1 | `"Qwen3 27B"` |
|
|
452
|
+
| `params_string`, `size_bytes`, `bits_per_weight` | v1 | `"27B"`, `17742039110`, `4` |
|
|
453
|
+
| `variants`, `selected_variant` | v1 | the downloaded quantizations, and which one is selected |
|
|
454
|
+
| `reasoning_options` | v1 | `[{type: "effort", values: ["none", "low", "medium", "xhigh"], default: "xhigh"}, {type: "toggle"}]` |
|
|
455
|
+
| `loaded_context_length` | v1 | `8192` (the loaded instance's window, which can be smaller than `context_window`) |
|
|
456
|
+
| `remaining_ttl_seconds` | v1 | `1050` — how long before an idle JIT-loaded instance evicts |
|
|
457
|
+
|
|
458
|
+
`Model#capabilities` picks up `function_calling` and `tool_choice` from the
|
|
459
|
+
model's tool-use training, `vision` from its vision flag, and `reasoning` from
|
|
460
|
+
its reasoning options. `streaming` and `structured_output` hold for every chat
|
|
461
|
+
model LM Studio serves: the server constrains generation with a grammar built
|
|
462
|
+
from your schema, so structured output works even on weights with no tool
|
|
463
|
+
training (whether the *values* are any good is a separate question — see the
|
|
464
|
+
gpt-oss caveat in `spec/support/models.rb`).
|
|
465
|
+
|
|
466
|
+
## The packaged catalog (models.json)
|
|
467
|
+
|
|
468
|
+
The gem ships a `models.json` at its root. **This file is only a sample**,
|
|
469
|
+
recorded from the author's machine: LM Studio serves whatever models *you*
|
|
470
|
+
have downloaded, so your catalog will be different. RubyLLM loads the shipped
|
|
471
|
+
file as the provider's model registry so `RubyLLM.models` has something to
|
|
472
|
+
show.
|
|
473
|
+
|
|
474
|
+
Note that `RubyLLM.models.refresh` does **not** query your LM Studio server:
|
|
475
|
+
provider gems that ship a registry file are excluded from the live fetch, and
|
|
476
|
+
refresh just re-reads the registered `models.json`. Your live server is still
|
|
477
|
+
the source of truth, reached differently depending on what you need:
|
|
478
|
+
|
|
479
|
+
- **Chatting** needs no catalog at all — any model id LM Studio knows will
|
|
480
|
+
work.
|
|
481
|
+
- **Browsing what your server offers** is `provider.list_models`, which hits
|
|
482
|
+
the live endpoints every time.
|
|
483
|
+
- **Making `RubyLLM.models` reflect your machine** means registering your own
|
|
484
|
+
catalog file in place of the shipped sample:
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
require 'ruby_llm/providers/lms'
|
|
488
|
+
|
|
489
|
+
provider = RubyLLM::Provider.resolve!(:lms).new(RubyLLM.config)
|
|
490
|
+
RubyLLM::Models.new(provider.list_models).save_to_json('my_models.json')
|
|
491
|
+
RubyLLM::Provider.register :lms, RubyLLM::Providers::LMS, models: 'my_models.json'
|
|
492
|
+
RubyLLM.models.refresh
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Don't edit the `models.json` inside the installed gem — it is overwritten on
|
|
496
|
+
every gem update; re-register with your own file instead.
|
|
497
|
+
|
|
498
|
+
## Model lifecycle: JIT loading, TTL, auto-evict
|
|
148
499
|
|
|
149
500
|
Because LM Studio only serves models you have downloaded, the server manages
|
|
150
501
|
memory rather than a fleet:
|
|
151
502
|
|
|
152
503
|
- **JIT loading** — with just-in-time loading enabled (the server default),
|
|
153
504
|
`/v1/models` lists every downloaded model and an inference request for an
|
|
154
|
-
unloaded model loads it on demand. Expect a long time-to-first-token on
|
|
155
|
-
|
|
156
|
-
- **TTL** — JIT-loaded models default to a 60-minute idle TTL; override it
|
|
157
|
-
|
|
158
|
-
`lms load <model> --ttl 3600`. Models loaded explicitly via `lms load`
|
|
159
|
-
|
|
160
|
-
- **Auto-evict** — by default LM Studio keeps at most one JIT-loaded model
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
The `state` field in
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
MCP servers declared in the request, with `allowed_tools` filtering.
|
|
187
|
-
- **Per-request context length** — `/api/v1/chat` accepts a
|
|
188
|
-
`context_length` override.
|
|
189
|
-
- **Model management** — `/api/v1/load`, `/api/v1/unload`, `/api/v1/download`,
|
|
190
|
-
and `/api/v1/download-status` control what is in memory and on disk.
|
|
191
|
-
|
|
192
|
-
## Development
|
|
505
|
+
unloaded model loads it on demand. Expect a long time-to-first-token on that
|
|
506
|
+
first request. With JIT off, only already-loaded models are served.
|
|
507
|
+
- **TTL** — JIT-loaded models default to a 60-minute idle TTL; override it per
|
|
508
|
+
request with the `ttl` option, or at load time with
|
|
509
|
+
`lms load <model> --ttl 3600`. Models loaded explicitly via `lms load` have
|
|
510
|
+
no TTL and stay resident until unloaded.
|
|
511
|
+
- **Auto-evict** — by default LM Studio keeps at most one JIT-loaded model in
|
|
512
|
+
memory, evicting the previous one before loading the next. Turn auto-evict
|
|
513
|
+
off in the server settings to keep several models resident at once (each
|
|
514
|
+
still subject to its own TTL).
|
|
515
|
+
|
|
516
|
+
The `state` field in model metadata (`loaded` / `not-loaded`) tells you which
|
|
517
|
+
models are resident right now.
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
# Development
|
|
522
|
+
|
|
523
|
+
## Tested against
|
|
524
|
+
|
|
525
|
+
| Component | Version |
|
|
526
|
+
| --- | --- |
|
|
527
|
+
| LM Studio (macOS) | 0.4.24+1 |
|
|
528
|
+
| `lms` CLI | commit `ff50809` |
|
|
529
|
+
| RubyLLM | 2.0.0.rc3 |
|
|
530
|
+
|
|
531
|
+
This is the server the committed VCR cassettes were recorded from, and the
|
|
532
|
+
version every endpoint and capability claim above was checked against. LM
|
|
533
|
+
Studio's OpenAI-compatible surface is stable, but its native REST APIs
|
|
534
|
+
(`/api/v0`, `/api/v1`) still change between releases — check your own
|
|
535
|
+
`lms --version` and the LM Studio release notes before assuming an endpoint
|
|
536
|
+
behaves as documented here.
|
|
193
537
|
|
|
194
538
|
Start the LM Studio server (`lms server start`), then:
|
|
195
539
|
|
|
@@ -199,16 +543,35 @@ bundle exec rake # rubocop, flay, archspec, specs
|
|
|
199
543
|
```
|
|
200
544
|
|
|
201
545
|
`rake models` calls the local server's model-listing endpoints and rewrites
|
|
202
|
-
the sample `models.json` at the gem root
|
|
203
|
-
|
|
204
|
-
users run.
|
|
546
|
+
the sample `models.json` at the gem root — it is a maintainer task for
|
|
547
|
+
refreshing the shipped sample, not something gem users run.
|
|
205
548
|
|
|
206
549
|
The suite always runs the provider integration specs. The first local run
|
|
207
550
|
calls the API and records VCR cassettes; CI only replays committed cassettes.
|
|
208
|
-
|
|
209
|
-
|
|
551
|
+
|
|
552
|
+
A failing example keeps its cassette, so a red suite stays red. To re-record
|
|
553
|
+
against the live server — after a deliberate behavior change, or an LM Studio
|
|
554
|
+
upgrade — run with `RERECORD=1`, which deletes the cassette of any example
|
|
555
|
+
that fails so the next run records it fresh:
|
|
556
|
+
|
|
557
|
+
```sh
|
|
558
|
+
RERECORD=1 bundle exec rspec spec/ruby_llm/chat_spec.rb
|
|
559
|
+
```
|
|
210
560
|
|
|
211
561
|
After refreshing the catalog, put real model IDs from your machine into
|
|
212
|
-
`spec/support/models.rb`. Keep only the operation matrices LM Studio supports
|
|
213
|
-
|
|
214
|
-
|
|
562
|
+
`spec/support/models.rb`. Keep only the operation matrices LM Studio supports;
|
|
563
|
+
the ones that need a particular kind of model are commented there:
|
|
564
|
+
|
|
565
|
+
| Matrix | Needs |
|
|
566
|
+
| --- | --- |
|
|
567
|
+
| `CHAT_MODELS`, `TOOL_MODELS` | anything LM Studio serves |
|
|
568
|
+
| `TOOL_CHOICE_MODELS` | a model that obeys `tool_choice: "required"` (gpt-oss does not) |
|
|
569
|
+
| `STRUCTURED_OUTPUT_MODELS` | a model that does not mangle `json_schema` values (gpt-oss does) |
|
|
570
|
+
| `REASONING_MODELS` | a model whose listing reports reasoning options |
|
|
571
|
+
| `REASONING_OFF_MODELS` | one of those that also reports LM Studio's `off` |
|
|
572
|
+
| `VISION_MODELS` | a model flagged `vision: true` |
|
|
573
|
+
| `EMBEDDING_MODELS` | an embedding model |
|
|
574
|
+
| `NATIVE_CHAT_MODELS`, `NATIVE_REASONING_MODELS`, `MANAGEMENT_MODEL` | small models, so load/unload cycles stay fast |
|
|
575
|
+
|
|
576
|
+
The portable contract specs are adapted from
|
|
577
|
+
[RubyLLM's live specs](https://github.com/crmne/ruby_llm/tree/main/spec/ruby_llm).
|