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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class LMS < Provider
6
+ # LM Studio serves Anthropic's Messages API alongside the OpenAI and
7
+ # native surfaces, so RubyLLM's stock Anthropic protocol drives it
8
+ # unchanged apart from the request path: Protocols::Anthropic posts to
9
+ # 'v1/messages' against a bare host, while this provider's api_base
10
+ # already ends in /v1. Streaming follows automatically, because the
11
+ # protocol's stream_url delegates to completion_url.
12
+ #
13
+ # Unlike the real Anthropic API, LM Studio wants no credentials and no
14
+ # anthropic-version header.
15
+ #
16
+ # Model listing and embeddings are not this protocol's business: both
17
+ # stay on the OpenAI-compatible endpoints LM Studio serves on the same
18
+ # port, routed there by the provider itself.
19
+ #
20
+ # RubyLLM.configure { |config| config.lms_protocol = :anthropic }
21
+ class AnthropicMessages < Protocols::Anthropic
22
+ def completion_url
23
+ 'messages'
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Endpoint paths for the LM Studio API integration. LM Studio serves its
6
+ # native REST API beside the OpenAI-compatible base rather than under it,
7
+ # so these are spelled relative to an api_base ending in /v1.
8
+ #
9
+ # They live on the provider rather than in the modules that use them
10
+ # because two of those modules are mixed into different objects — Models
11
+ # into the protocols, ModelManagement into the provider — and a path
12
+ # spelled twice is a path that can drift.
13
+ class LMS < Provider
14
+ NATIVE_V1_MODELS_URL = '../api/v1/models'
15
+ NATIVE_V0_MODELS_URL = '../api/v0/models'
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class LMS < Provider
6
+ # Model-management methods backed by LM Studio's native REST API
7
+ # (`/api/v1/models` and friends, reached relative to the OpenAI-compatible
8
+ # API base). They control what is in memory and on disk on the server:
9
+ # listing downloaded models with their loaded instances, loading a model
10
+ # (optionally with a context-length override), unloading an instance,
11
+ # and downloading a new model.
12
+ #
13
+ # Every write here is sent non-idempotent. RubyLLM's Faraday stack
14
+ # retries a POST that times out or loses its connection, and none of
15
+ # these are safe to replay: LM Studio answers a second load of a
16
+ # resident model by starting a second instance, so a slow load that
17
+ # timed out would end up holding the weights in memory twice.
18
+ module ModelManagement
19
+ # Every downloaded model with its native details (architecture,
20
+ # quantization, capabilities, loaded_instances, ...), as an Array of
21
+ # Hashes straight from the server.
22
+ def native_models
23
+ Array(connection.get(NATIVE_V1_MODELS_URL).body['models'])
24
+ end
25
+
26
+ # The subset of #native_models with at least one loaded instance.
27
+ def loaded_models
28
+ native_models.select { |model| Array(model['loaded_instances']).any? }
29
+ end
30
+
31
+ # Loads +model+ into memory. Returns the server's response Hash,
32
+ # whose 'instance_id' is what #unload_model takes. Extra keyword
33
+ # options go into the request as-is (the server rejects unknown keys).
34
+ def load_model(model, context_length: nil, **options)
35
+ payload = { model: model, context_length: context_length, **options }.compact
36
+ post_model_action('load', payload)
37
+ end
38
+
39
+ # Unloads the loaded instance named +instance_id+ (from #load_model's
40
+ # response or a native listing's 'loaded_instances').
41
+ def unload_model(instance_id)
42
+ post_model_action('unload', { instance_id: instance_id })
43
+ end
44
+
45
+ # Asks the server to download +model+ (a model key such as
46
+ # 'qwen/qwen3-4b'). Extra keyword options go into the request as-is.
47
+ def download_model(model, **)
48
+ post_model_action('download', { model: model, ** })
49
+ end
50
+
51
+ private
52
+
53
+ def post_model_action(action, payload)
54
+ connection.post("#{NATIVE_V1_MODELS_URL}/#{action}", payload, idempotent: false).body
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -5,20 +5,31 @@ module RubyLLM
5
5
  class LMS < Provider
6
6
  # Model-listing methods for the LM Studio API integration.
7
7
  # Enriches the OpenAI-compatible /v1/models listing with details from
8
- # LM Studio's native REST API (/api/v0/models): model type, architecture,
9
- # quantization, load state, and context length. The native endpoint is
10
- # optional — when it is unavailable the plain listing still works.
8
+ # LM Studio's native REST API: model type, architecture, quantization,
9
+ # load state, context length, and reasoning controls. Both native
10
+ # listings are optional — when neither answers the plain listing still
11
+ # works.
12
+ #
13
+ # RubyLLM always lists through the provider's default protocol
14
+ # (Provider#listing_protocol), so this belongs on :chat_completions
15
+ # alone — the other protocols reach it through that, whatever
16
+ # lms_protocol selects.
11
17
  module Models
12
- NATIVE_MODELS_URL = '../api/v0/models'
18
+ include NativeV1
19
+
13
20
  CAPABILITY_MAP = {
14
- 'tool_use' => 'function_calling',
15
- 'vision' => 'vision'
21
+ 'tool_use' => %w[function_calling tool_choice],
22
+ 'vision' => %w[vision]
16
23
  }.freeze
17
24
 
18
25
  def models_url
19
26
  'models'
20
27
  end
21
28
 
29
+ # What LM Studio's server provides for every chat model it serves,
30
+ # whatever the weights were trained for. Structured output is on this
31
+ # list because the server constrains generation with a grammar built
32
+ # from the schema, so it holds even for models with no tool training.
22
33
  def model_capabilities
23
34
  %w[streaming structured_output]
24
35
  end
@@ -33,7 +44,7 @@ module RubyLLM
33
44
  detail = details[model['id']] || {}
34
45
  Model.new(
35
46
  id: model['id'],
36
- name: model['id'],
47
+ name: detail['display_name'] || model['id'],
37
48
  provider: slug,
38
49
  family: detail['arch'] || 'lms',
39
50
  created_at: model['created'] ? Time.at(model['created']) : nil,
@@ -58,8 +69,9 @@ module RubyLLM
58
69
  return [] if embedding?(detail)
59
70
 
60
71
  reported = Array(detail['capabilities'])
61
- derived = CAPABILITY_MAP.filter_map { |native, capability| capability if reported.include?(native) }
72
+ derived = CAPABILITY_MAP.filter_map { |native, names| names if reported.include?(native) }.flatten
62
73
  derived << 'vision' if vision?(detail) && !derived.include?('vision')
74
+ derived << 'reasoning' if detail['reasoning_options']
63
75
  model_capabilities + derived
64
76
  end
65
77
 
@@ -70,7 +82,17 @@ module RubyLLM
70
82
  arch: detail['arch'],
71
83
  compatibility_type: detail['compatibility_type'],
72
84
  quantization: detail['quantization'],
73
- state: detail['state']
85
+ bits_per_weight: detail['bits_per_weight'],
86
+ state: detail['state'],
87
+ display_name: detail['display_name'],
88
+ description: detail['description'],
89
+ params_string: detail['params_string'],
90
+ size_bytes: detail['size_bytes'],
91
+ variants: detail['variants'],
92
+ selected_variant: detail['selected_variant'],
93
+ reasoning_options: detail['reasoning_options'],
94
+ loaded_context_length: detail['loaded_context_length'],
95
+ remaining_ttl_seconds: detail['remaining_ttl_seconds']
74
96
  }.compact
75
97
  end
76
98
 
@@ -84,11 +106,21 @@ module RubyLLM
84
106
 
85
107
  private
86
108
 
109
+ # LM Studio serves two native model listings. v1 is the richer one but
110
+ # only exists on newer releases; v0 is served by everything that has a
111
+ # native API at all. Try v1, fall back to v0, then give up quietly.
87
112
  def native_model_details
88
- Array(@connection.get(NATIVE_MODELS_URL).body['data']).to_h { |detail| [detail['id'], detail] }
113
+ native_details(NATIVE_V1_MODELS_URL, 'models') { |model| normalize_v1_detail(model) } ||
114
+ native_details(NATIVE_V0_MODELS_URL, 'data') { |detail| detail } ||
115
+ {}
116
+ end
117
+
118
+ def native_details(url, key)
119
+ entries = Array(@connection.get(url).body[key])
120
+ entries.to_h { |entry| [entry['key'] || entry['id'], yield(entry)] }
89
121
  rescue Error, Faraday::Error => e
90
- RubyLLM.logger.debug "LM Studio native model details unavailable (#{e.message})."
91
- {}
122
+ RubyLLM.logger.debug "LM Studio native model details unavailable at #{url} (#{e.message})."
123
+ nil
92
124
  end
93
125
  end
94
126
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class LMS < Provider
6
+ class NativeChat < RubyLLM::Protocol
7
+ # Maps RubyLLM's role-tagged message history onto the native API's
8
+ # stateful conversation model: system messages become the
9
+ # system_prompt, the last assistant response supplies a
10
+ # previous_response_id to continue from, and only the messages
11
+ # after it are rendered as input.
12
+ module Conversation
13
+ RESPONSE_ID_PATTERN = /"response_id"\s*:\s*"(resp_[0-9a-f]+)"/
14
+
15
+ # What an assistant message has to carry for the next turn to
16
+ # continue from it. It goes on Message#raw_content because that is
17
+ # the slot RubyLLM persists and rebuilds (see
18
+ # ActiveRecord::MessageMethods#to_llm) — Message#raw holds a
19
+ # Faraday::Response that lasts only as long as the request, so a
20
+ # chat reloaded from the database would have nothing to continue
21
+ # from. The stock Mistral and Gemini stateful protocols use
22
+ # raw_content the same way.
23
+ def conversation_state(data)
24
+ response_id = data['response_id']
25
+ { 'response_id' => response_id } if response_id
26
+ end
27
+
28
+ # The `response_id` of the stored server-side response +message+
29
+ # came from, or nil. Prefers the persisted copy, then the parsed
30
+ # response body, then raw SSE text.
31
+ def response_id_from(message)
32
+ return nil unless message.role == :assistant
33
+
34
+ stored_response_id(message.raw_content) || stored_response_id(message.raw&.body)
35
+ end
36
+
37
+ def stored_response_id(source)
38
+ case source
39
+ when Hash then source['response_id']
40
+ when String then source.scan(RESPONSE_ID_PATTERN).flatten.last
41
+ end
42
+ end
43
+
44
+ def system_prompt_from(messages)
45
+ prompts = messages.select { |msg| msg.role == :system }.map { |msg| msg.content.to_s }
46
+ prompts.empty? ? nil : prompts.join("\n\n")
47
+ end
48
+
49
+ # Splits the non-system conversation at the last assistant message
50
+ # whose response_id is known: that id continues the stored chat and
51
+ # only the messages after it are sent as input.
52
+ def split_conversation(conversation)
53
+ last_continuable = conversation.rindex { |msg| response_id_from(msg) }
54
+ return [nil, ensure_replayable!(conversation)] unless last_continuable
55
+
56
+ [response_id_from(conversation[last_continuable]),
57
+ ensure_replayable!(conversation[(last_continuable + 1)..])]
58
+ end
59
+
60
+ def ensure_replayable!(messages)
61
+ return messages unless messages.any? { |msg| msg.role == :assistant }
62
+
63
+ raise Error,
64
+ "LM Studio's native chat API cannot replay assistant history; it continues " \
65
+ 'stored conversations by response_id. Keep store enabled (the default) so ' \
66
+ 'responses carry one, or use the :chat_completions protocol.'
67
+ end
68
+
69
+ def render_input(messages)
70
+ parts = messages.flat_map { |msg| input_parts(msg) }
71
+ return parts.first[:content] if parts.length == 1 && parts.first[:type] == 'text'
72
+
73
+ parts
74
+ end
75
+
76
+ def input_parts(msg)
77
+ parts = []
78
+ content = msg.content.to_s
79
+ parts << { type: 'text', content: content } unless content.empty?
80
+ msg.attachments.each { |attachment| parts << input_image_part(attachment) }
81
+ parts
82
+ end
83
+
84
+ def input_image_part(attachment)
85
+ raise UnsupportedAttachmentError, attachment.mime_type unless attachment.type == :image
86
+
87
+ { type: 'image', data_url: attachment.url? ? attachment.source.to_s : attachment.for_llm }
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class LMS < Provider
6
+ class NativeChat < RubyLLM::Protocol
7
+ # Streaming methods for LM Studio's native chat API. The server
8
+ # sends named SSE events (message.delta, reasoning.delta, chat.end,
9
+ # plus bookkeeping like prompt_processing.* and model_load.*), each
10
+ # carrying a JSON object with a matching 'type'.
11
+ module Streaming
12
+ # The native stream ends with a `chat.end` event whose `result` is
13
+ # the same object a non-streamed call returns. Streaming leaves the
14
+ # Faraday body as raw SSE text, so the result object replaces it —
15
+ # then `message.raw.body` looks the same either way, response_id
16
+ # and stats included.
17
+ def stream_response(payload, additional_headers = {})
18
+ accumulator = RubyLLM::Protocol::StreamAccumulator.new
19
+ result = nil
20
+ response = stream_events(stream_url, payload, additional_headers) do |data|
21
+ result = data['result'] if data['type'] == 'chat.end' && data['result']
22
+ chunk = build_chunk(data)
23
+ accumulator.add chunk
24
+ yield chunk
25
+ end
26
+ response.env[:body] = result if result
27
+ accumulator.to_message(response)
28
+ end
29
+
30
+ def build_chunk(data)
31
+ case data['type']
32
+ when 'message.delta'
33
+ Chunk.new(role: :assistant, content: data['content'])
34
+ when 'reasoning.delta'
35
+ Chunk.new(role: :assistant, content: nil, thinking: Thinking.build(text: data['content']))
36
+ when 'chat.end'
37
+ result_chunk(data['result'] || {})
38
+ else
39
+ Chunk.new(role: :assistant, content: nil)
40
+ end
41
+ end
42
+
43
+ def result_chunk(result)
44
+ stats = result['stats'] || {}
45
+ Chunk.new(
46
+ role: :assistant,
47
+ content: nil,
48
+ model: result['model_instance_id'],
49
+ input_tokens: stats['input_tokens'],
50
+ output_tokens: stats['total_output_tokens'],
51
+ thinking_tokens: stats['reasoning_output_tokens'],
52
+ finish_reason: finish_reason_for(stats),
53
+ raw_content: conversation_state(result)
54
+ )
55
+ end
56
+
57
+ def parse_streaming_error(data)
58
+ error = JSON.parse(data)['error']
59
+ [500, error.is_a?(Hash) ? error['message'] : error.to_s]
60
+ rescue JSON::ParserError
61
+ [500, data]
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class LMS < Provider
6
+ # Chat protocol for LM Studio's native REST API (`POST /api/v1/chat`).
7
+ # Select it with `protocol: :native_chat` on the chat or
8
+ # `config.lms_protocol = :native_chat`.
9
+ #
10
+ # The native API is stateful: LM Studio stores each response
11
+ # server-side and continues a conversation from a `response_id`
12
+ # rather than replaying role-tagged history. This protocol makes
13
+ # RubyLLM's ordinary multi-turn Chat work on top of that: each
14
+ # request sends only the messages newer than the last assistant
15
+ # response and points `previous_response_id` at it. The id is kept on
16
+ # `message.raw_content`, which RubyLLM persists, so a conversation
17
+ # reloaded from the database continues rather than raising. Stats and
18
+ # server-side tool calls ride on `message.raw.body`.
19
+ #
20
+ # Native-only capabilities reach the request through
21
+ # `with_provider_options`: `integrations:` (MCP plugins or ephemeral
22
+ # MCP servers the server runs itself), `context_length:`, the sampling
23
+ # settings the native schema accepts (`top_p`, `top_k`, `min_p`,
24
+ # `repeat_penalty`), and `store: false` (which trades multi-turn
25
+ # continuity for privacy).
26
+ # Client-side RubyLLM tools and structured output are not part of
27
+ # this wire format — the :chat_completions protocol serves those.
28
+ class NativeChat < RubyLLM::Protocol
29
+ include NativeChat::Conversation
30
+ include NativeChat::Streaming
31
+
32
+ def completion_url
33
+ '../api/v1/chat'
34
+ end
35
+
36
+ def stream_url
37
+ completion_url
38
+ end
39
+
40
+ # rubocop:disable-next Lint/UnusedMethodArgument
41
+ def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil,
42
+ schema: nil, thinking: nil, citations: false, caching: nil, tool_prefs: nil)
43
+ ensure_supported!(tools: tools, schema: schema)
44
+ previous_response_id, pending = split_conversation(messages.reject { |msg| msg.role == :system })
45
+
46
+ # Kept for parse_completion_body: the response reports no stop
47
+ # reason, so the ceiling the request asked for is the only way to
48
+ # recognize a truncated generation.
49
+ @max_output_tokens = max_output_tokens
50
+
51
+ payload = { model: model.id, input: render_input(pending), stream: stream }
52
+ system_prompt = system_prompt_from(messages)
53
+ payload[:system_prompt] = system_prompt if system_prompt
54
+ payload[:previous_response_id] = previous_response_id if previous_response_id
55
+ payload[:temperature] = temperature unless temperature.nil?
56
+ payload[:max_output_tokens] = max_output_tokens unless max_output_tokens.nil?
57
+ reasoning = resolve_reasoning(thinking)
58
+ payload[:reasoning] = reasoning if reasoning
59
+ payload
60
+ end
61
+
62
+ def parse_completion_body(data, raw:)
63
+ raise Error.new(error_message(data), response: raw) if data['error']
64
+
65
+ output = Array(data['output'])
66
+ raise Error.new('LM Studio returned no output items', response: raw) if output.empty?
67
+
68
+ stats = data['stats'] || {}
69
+ Message.new(
70
+ role: :assistant,
71
+ content: output_text(output),
72
+ thinking: Thinking.build(text: reasoning_text(output)),
73
+ input_tokens: stats['input_tokens'],
74
+ output_tokens: stats['total_output_tokens'],
75
+ thinking_tokens: stats['reasoning_output_tokens'],
76
+ finish_reason: finish_reason_for(stats),
77
+ model: data['model_instance_id'],
78
+ raw_content: conversation_state(data),
79
+ raw: raw
80
+ )
81
+ end
82
+
83
+ # Maps RubyLLM thinking options onto the native `reasoning` setting.
84
+ # The native endpoint's vocabulary is 'off' | 'low' | 'medium' |
85
+ # 'high' | 'xhigh' | 'on' (models accept a subset), where the
86
+ # registry — and so the OpenAI endpoint — spells off as 'none'.
87
+ def resolve_reasoning(thinking)
88
+ return nil unless thinking
89
+ return 'off' if thinking.respond_to?(:disabled?) && thinking.disabled?
90
+
91
+ effort = thinking.respond_to?(:effort) ? thinking.effort : nil
92
+ effort ? effort.to_s : 'on'
93
+ end
94
+
95
+ # The native response carries no stop reason, so a generation that
96
+ # ran into the requested ceiling is only recognizable by its token
97
+ # count. Without this a truncated answer looked complete.
98
+ def finish_reason_for(stats)
99
+ return :stop unless @max_output_tokens
100
+
101
+ stats['total_output_tokens'].to_i >= @max_output_tokens ? :length : :stop
102
+ end
103
+
104
+ def ensure_supported!(tools:, schema:)
105
+ if tools&.any?
106
+ raise Error,
107
+ "LM Studio's native chat API does not run client-side tools; use the " \
108
+ ':chat_completions protocol for RubyLLM tools, or have the server run MCP ' \
109
+ 'tools with with_provider_options(integrations: [...])'
110
+ end
111
+ return unless schema
112
+
113
+ raise Error,
114
+ "LM Studio's native chat API does not support structured output; " \
115
+ 'use the :chat_completions protocol for with_schema'
116
+ end
117
+
118
+ def error_message(data)
119
+ error = data['error']
120
+ error.is_a?(Hash) ? error['message'] : error.to_s
121
+ end
122
+
123
+ def output_text(output)
124
+ text = output.filter_map { |item| item['content'] if item['type'] == 'message' }.join
125
+ text.empty? ? nil : text
126
+ end
127
+
128
+ def reasoning_text(output)
129
+ output.filter_map { |item| item['content'] if item['type'] == 'reasoning' }.join
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class LMS < Provider
6
+ # Rewrites entries from LM Studio's native v1 model listing
7
+ # (GET /api/v1/models) into the vocabulary of the older v0 listing,
8
+ # which is what LMS::Models reads. v1 states things differently —
9
+ # capabilities as a Hash of flags rather than an Array of names,
10
+ # vision as a capability rather than a model type, load state as a
11
+ # list of running instances — and carries several fields v0 has no
12
+ # equivalent for.
13
+ module NativeV1
14
+ # LM Studio spells its reasoning settings 'off', 'low', 'medium',
15
+ # 'high', 'xhigh', 'on'. RubyLLM's registry speaks OpenAI's
16
+ # vocabulary instead: off is an effort named 'none', and a plain
17
+ # on/off switch is a separate :toggle option rather than an effort.
18
+ # Translating here is what lets Thinking::Controls — and so
19
+ # Chat#with_thinking — find any controls at all.
20
+ REASONING_EFFORT_ALIASES = { 'off' => 'none' }.freeze
21
+ REASONING_TOGGLE = 'on'
22
+
23
+ # Rewrites one v1 entry, keeping the v1-only fields (display name,
24
+ # parameter count, size on disk, description, variants, reasoning
25
+ # controls, and the loaded instance's context length and idle TTL)
26
+ # alongside the v0 ones.
27
+ def normalize_v1_detail(model)
28
+ instance = Array(model['loaded_instances']).first
29
+ capabilities = model['capabilities'] || {}
30
+ {
31
+ 'type' => normalize_v1_type(model, capabilities),
32
+ 'publisher' => model['publisher'],
33
+ 'arch' => model['architecture'],
34
+ 'compatibility_type' => model['format'],
35
+ 'quantization' => model.dig('quantization', 'name'),
36
+ 'bits_per_weight' => model.dig('quantization', 'bits_per_weight'),
37
+ 'state' => instance ? 'loaded' : 'not-loaded',
38
+ 'max_context_length' => model['max_context_length'],
39
+ 'capabilities' => normalize_v1_capabilities(capabilities),
40
+ 'display_name' => model['display_name'],
41
+ 'params_string' => model['params_string'],
42
+ 'size_bytes' => model['size_bytes'],
43
+ 'description' => model['description'],
44
+ 'variants' => model['variants'],
45
+ 'selected_variant' => model['selected_variant'],
46
+ 'reasoning_options' => normalize_v1_reasoning(capabilities['reasoning']),
47
+ 'loaded_context_length' => instance&.dig('config', 'context_length'),
48
+ 'remaining_ttl_seconds' => instance&.dig('remaining_ttl_seconds')
49
+ }.compact
50
+ end
51
+
52
+ # v1 reports only 'llm' and 'embedding' and states vision as a
53
+ # capability; v0 spells those 'embeddings' and 'vlm'.
54
+ def normalize_v1_type(model, capabilities)
55
+ return 'embeddings' if model['type'] == 'embedding'
56
+ return 'vlm' if capabilities['vision']
57
+
58
+ model['type']
59
+ end
60
+
61
+ # v1 reports capabilities as a Hash of flags, v0 as an Array of names.
62
+ def normalize_v1_capabilities(capabilities)
63
+ names = []
64
+ names << 'vision' if capabilities['vision']
65
+ names << 'tool_use' if capabilities['trained_for_tool_use']
66
+ names
67
+ end
68
+
69
+ # Turns the v1 reasoning block into RubyLLM's reasoning_options: an
70
+ # :effort option listing the graded settings, and a :toggle when the
71
+ # model accepts LM Studio's plain 'on'. Returns nil for a model that
72
+ # reports no reasoning at all, so the key compacts away.
73
+ def normalize_v1_reasoning(reasoning)
74
+ allowed = Array(reasoning && reasoning['allowed_options'])
75
+ return nil if allowed.empty?
76
+
77
+ efforts = allowed.reject { |option| option == REASONING_TOGGLE }
78
+ .map { |option| REASONING_EFFORT_ALIASES.fetch(option, option) }
79
+
80
+ options = []
81
+ options << reasoning_effort_option(efforts, reasoning['default']) if efforts.any?
82
+ options << { 'type' => 'toggle' } if allowed.include?(REASONING_TOGGLE)
83
+ options
84
+ end
85
+
86
+ # The listing's default is only an effort default when it names one:
87
+ # a model whose default is 'on' is defaulting to its toggle instead.
88
+ def reasoning_effort_option(efforts, default)
89
+ default = REASONING_EFFORT_ALIASES.fetch(default, default)
90
+ option = { 'type' => 'effort', 'values' => efforts }
91
+ option['default'] = default if efforts.include?(default)
92
+ option
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class LMS < Provider
6
+ # LM Studio serves OpenAI's Responses API at POST /v1/responses, and
7
+ # RubyLLM's stock Responses protocol drives it — except for reasoning,
8
+ # where the two disagree about where the text lives.
9
+ #
10
+ # OpenAI never returns raw reasoning, only an optional summary, so a
11
+ # reasoning item reads:
12
+ #
13
+ # { type: 'reasoning', summary: [{ type: 'summary_text', text: '...' }] }
14
+ #
15
+ # LM Studio runs the model locally and has nothing to hide, so it
16
+ # returns the reasoning itself, leaving summary empty:
17
+ #
18
+ # { type: 'reasoning', summary: [], content: [{ type: 'reasoning_text', text: '...' }] }
19
+ #
20
+ # and streams it as `response.reasoning_text.delta` rather than
21
+ # `response.reasoning_summary_text.delta`. Reading only OpenAI's shape
22
+ # loses the reasoning on both paths — the response spends reasoning
23
+ # tokens and `message.thinking` comes back nil.
24
+ class Responses < Protocols::Responses
25
+ REASONING_TEXT_DELTA = 'response.reasoning_text.delta'
26
+
27
+ def parse_reasoning_summary(output)
28
+ super || parse_reasoning_text(output)
29
+ end
30
+
31
+ def parse_reasoning_text(output)
32
+ texts = output.select { |item| item['type'] == 'reasoning' }.flat_map do |item|
33
+ Array(item['content']).filter_map { |part| part['text'] if part['type'] == 'reasoning_text' }
34
+ end
35
+
36
+ texts.empty? ? nil : texts.join("\n")
37
+ end
38
+
39
+ def build_chunk(data)
40
+ return super unless data['type'] == REASONING_TEXT_DELTA
41
+
42
+ chunk thinking: Thinking.build(text: data['delta'])
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Version of the ruby_llm-providers-lms gem. Kept in its own file so the
6
+ # gemspec can read the literal without loading the provider.
7
+ class LMS < Provider
8
+ VERSION = '0.2.0'
9
+
10
+ def self.version
11
+ VERSION
12
+ end
13
+ end
14
+ end
15
+ end