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
|
@@ -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
|
|
9
|
-
#
|
|
10
|
-
# optional — when
|
|
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
|
-
|
|
18
|
+
include NativeV1
|
|
19
|
+
|
|
13
20
|
CAPABILITY_MAP = {
|
|
14
|
-
'tool_use' =>
|
|
15
|
-
'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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|