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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +121 -0
  3. data/README.md +467 -104
  4. data/lib/ruby_llm/providers/lms/anthropic_messages.rb +28 -0
  5. data/lib/ruby_llm/providers/lms/endpoints.rb +18 -0
  6. data/lib/ruby_llm/providers/lms/model_management.rb +59 -0
  7. data/lib/ruby_llm/providers/lms/models.rb +44 -12
  8. data/lib/ruby_llm/providers/lms/native_chat/conversation.rb +93 -0
  9. data/lib/ruby_llm/providers/lms/native_chat/streaming.rb +67 -0
  10. data/lib/ruby_llm/providers/lms/native_chat.rb +134 -0
  11. data/lib/ruby_llm/providers/lms/native_v1.rb +97 -0
  12. data/lib/ruby_llm/providers/lms/responses.rb +47 -0
  13. data/lib/ruby_llm/providers/lms/version.rb +15 -0
  14. data/lib/ruby_llm/providers/lms.rb +51 -1
  15. data/models.json +69 -454
  16. metadata +12 -37
  17. data/.flayignore +0 -1
  18. data/.github/workflows/ci.yml +0 -29
  19. data/.github/workflows/gitleaks.yml +0 -22
  20. data/.github/workflows/release.yml +0 -36
  21. data/.overcommit.yml +0 -31
  22. data/.rspec +0 -2
  23. data/.rubocop.yml +0 -29
  24. data/Archspec.rb +0 -14
  25. data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_handle_a_multi_turn_conversation.yml +0 -92
  26. data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_have_a_basic_conversation.yml +0 -45
  27. data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_use_tools.yml +0 -129
  28. data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_returns_the_raw_response.yml +0 -73
  29. data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_supports_streaming_responses.yml +0 -81
  30. data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_can_have_a_basic_conversation.yml +0 -73
  31. data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_returns_structured_output.yml +0 -73
  32. data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_returns_the_raw_response.yml +0 -73
  33. data/spec/fixtures/vcr_cassettes/rubyllm_embedding_lms_text_embedding_nomic_embed_text_v1_5_embeds_one_text.yml +0 -828
  34. data/spec/fixtures/vcr_cassettes/rubyllm_embedding_lms_text_embedding_nomic_embed_text_v1_5_embeds_several_texts.yml +0 -2375
  35. data/spec/ruby_llm/chat_schema_spec.rb +0 -30
  36. data/spec/ruby_llm/chat_spec.rb +0 -35
  37. data/spec/ruby_llm/chat_streaming_spec.rb +0 -22
  38. data/spec/ruby_llm/chat_tools_spec.rb +0 -30
  39. data/spec/ruby_llm/embedding_spec.rb +0 -49
  40. data/spec/ruby_llm/image_spec.rb +0 -23
  41. data/spec/ruby_llm/models_spec.rb +0 -11
  42. data/spec/ruby_llm/moderation_spec.rb +0 -22
  43. data/spec/ruby_llm/providers/lms/connection_guard_spec.rb +0 -47
  44. data/spec/ruby_llm/providers/lms_spec.rb +0 -153
  45. data/spec/ruby_llm/rerank_spec.rb +0 -23
  46. data/spec/ruby_llm/speech_spec.rb +0 -25
  47. data/spec/ruby_llm/video_spec.rb +0 -27
  48. data/spec/spec_helper.rb +0 -26
  49. data/spec/support/models.rb +0 -29
  50. data/spec/support/rubyllm_configuration.rb +0 -14
  51. 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 OpenAI-compatible server (`lms server start`,
38
+ local models through LM Studio's local server (`lms server start`,
5
39
  `http://localhost:1234/v1` by default).
6
40
 
7
- The provider works out of the box: no API key, no configuration. It speaks the
8
- Chat Completions dialect by default and also registers the Responses protocol
9
- (LM Studio serves `/v1/responses` too). Model listings are enriched with details
10
- from LM Studio's native REST API (`/api/v0/models`): architecture, quantization,
11
- load state, context length, and tool/vision capabilities.
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
- ## Usage
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
- ## The model catalog (models.json)
89
+ ---
56
90
 
57
- The gem ships a `models.json` at its root. **This file is only a sample**,
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
- Note that `RubyLLM.models.refresh` does **not** query your LM Studio server:
64
- provider gems that ship a registry file are excluded from the live fetch, and
65
- refresh just re-reads the registered `models.json`. Your live server is still
66
- the source of truth, reached differently depending on what you need:
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
- - **Chatting** needs no catalog at all any model id LM Studio knows will
69
- work (see above).
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
- ```ruby
76
- require 'ruby_llm/providers/lms'
102
+ ```ruby
103
+ RubyLLM.configure { |config| config.lms_protocol = :anthropic } # process-wide
77
104
 
78
- provider = RubyLLM::Provider.resolve!(:lms).new(RubyLLM.config)
79
- RubyLLM::Models.new(provider.list_models).save_to_json('my_models.json')
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
- Don't edit the `models.json` inside the installed gem — it is overwritten on
85
- every gem update; re-register with your own file instead.
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
- Embeddings work the same way:
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.embed('Hello world', model: 'text-embedding-nomic-embed-text-v1.5', provider: :lms)
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
- To use the Responses dialect instead of Chat Completions:
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
- ## LM Studio server options
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
- LM Studio serves three API surfaces on one port (`http://localhost:1234` by
102
- default). This gem talks to the first two:
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
- | Surface | Endpoints | Used by this gem |
105
- | --- | --- | --- |
106
- | OpenAI-compatible | `GET /v1/models`, `POST /v1/chat/completions`, `POST /v1/completions`, `POST /v1/embeddings`, `POST /v1/responses` | chat, tools, structured output, embeddings, Responses protocol |
107
- | Native REST v0 | `GET /api/v0/models`, `GET /api/v0/models/{model}`, `POST /api/v0/chat/completions`, `POST /api/v0/completions`, `POST /api/v0/embeddings` | model-listing enrichment |
108
- | Native REST v1 | `POST /api/v1/chat`, `GET /api/v1/list`, `POST /api/v1/load`, `POST /api/v1/unload`, `POST /api/v1/download`, `GET /api/v1/download-status` | not yet (see below) |
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
- ### Standard request parameters
239
+ ## Embeddings
111
240
 
112
- On its OpenAI-compatible chat endpoints LM Studio honors the usual OpenAI
113
- vocabulary: `model`, `messages`, `temperature`, `top_p`, `max_tokens`,
114
- `stream`, `stop`, `presence_penalty`, `frequency_penalty`, `logit_bias`,
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
- ### LM Studio extras
245
+ ```ruby
246
+ RubyLLM.embed('Hello world', model: 'text-embedding-nomic-embed-text-v1.5', provider: :lms)
247
+ ```
122
248
 
123
- LM Studio also accepts request fields that are not part of OpenAI's
124
- vocabulary. RubyLLM merges `with_provider_options` into the request payload
125
- as-is, so all of these work today:
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
- ### Model lifecycle: JIT loading, TTL, auto-evict
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
- that first request. With JIT off, only already-loaded models are served.
156
- - **TTL** — JIT-loaded models default to a 60-minute idle TTL; override it
157
- per request with the `ttl` option above, or at load time with
158
- `lms load <model> --ttl 3600`. Models loaded explicitly via `lms load`
159
- have no TTL and stay resident until unloaded.
160
- - **Auto-evict** — by default LM Studio keeps at most one JIT-loaded model
161
- in memory, evicting the previous one before loading the next. Turn
162
- auto-evict off in the server settings to keep several models resident at
163
- once (each still subject to its own TTL).
164
-
165
- The `state` field in this gem's model metadata (`loaded` / `not-loaded`)
166
- comes from the native v0 API and tells you which models are resident right
167
- now.
168
-
169
- ### Native REST APIs (not yet wired up)
170
-
171
- The native APIs offer capabilities the OpenAI-compatible surface cannot
172
- express. This gem does not use them yet, beyond the v0 model-listing
173
- enrichment:
174
-
175
- - **Performance stats** native v0/v1 chat responses include a `stats`
176
- object (tokens per second, time to first token, generation time, and
177
- draft-token acceptance counts when speculative decoding is active) plus
178
- `model_info` and `runtime` blocks.
179
- - **Stateful chats** — `/api/v1/chat` stores conversations server-side
180
- (`store`, default true) and continues them via `previous_response_id`.
181
- - **Reasoning control** `/api/v1/chat` takes a `reasoning` effort option
182
- (`off` / `low` / `medium` / `high` / `on`) and returns reasoning output as
183
- separate items.
184
- - **Server-side MCP tools** `/api/v1/chat` can run tools itself through
185
- `integrations`: pre-configured MCP plugins from `mcp.json` or ephemeral
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 (see "The model catalog" above)
203
- it is a maintainer task for refreshing the shipped sample, not something gem
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
- A failing example deletes its cassette so the next local run tests the live
209
- API again.
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
- (chat, tools, structured output, embeddings). The portable contract specs are
214
- adapted from [RubyLLM's live specs](https://github.com/crmne/ruby_llm/tree/main/spec/ruby_llm).
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).