ruby_llm-providers-lms 0.1.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 +7 -0
- data/.flayignore +1 -0
- data/.github/workflows/ci.yml +32 -0
- data/.github/workflows/gitleaks.yml +22 -0
- data/.github/workflows/release.yml +36 -0
- data/.overcommit.yml +31 -0
- data/.rspec +2 -0
- data/.rubocop.yml +29 -0
- data/Archspec.rb +14 -0
- data/LICENSE +21 -0
- data/README.md +87 -0
- data/lib/ruby_llm/providers/lms/models.rb +96 -0
- data/lib/ruby_llm/providers/lms.rb +55 -0
- data/models.json +603 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_handle_a_multi_turn_conversation.yml +92 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_have_a_basic_conversation.yml +45 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_use_tools.yml +129 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_returns_the_raw_response.yml +73 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_supports_streaming_responses.yml +81 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_can_have_a_basic_conversation.yml +73 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_returns_structured_output.yml +73 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_returns_the_raw_response.yml +73 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_embedding_lms_text_embedding_nomic_embed_text_v1_5_embeds_one_text.yml +828 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_embedding_lms_text_embedding_nomic_embed_text_v1_5_embeds_several_texts.yml +2375 -0
- data/spec/ruby_llm/chat_schema_spec.rb +30 -0
- data/spec/ruby_llm/chat_spec.rb +35 -0
- data/spec/ruby_llm/chat_streaming_spec.rb +22 -0
- data/spec/ruby_llm/chat_tools_spec.rb +30 -0
- data/spec/ruby_llm/embedding_spec.rb +49 -0
- data/spec/ruby_llm/image_spec.rb +23 -0
- data/spec/ruby_llm/models_spec.rb +11 -0
- data/spec/ruby_llm/moderation_spec.rb +22 -0
- data/spec/ruby_llm/providers/lms_spec.rb +149 -0
- data/spec/ruby_llm/rerank_spec.rb +23 -0
- data/spec/ruby_llm/speech_spec.rb +25 -0
- data/spec/ruby_llm/video_spec.rb +27 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/support/models.rb +29 -0
- data/spec/support/rubyllm_configuration.rb +14 -0
- data/spec/support/vcr_configuration.rb +16 -0
- metadata +99 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Chat, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
let(:person_schema) do
|
|
9
|
+
{
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
name: { type: 'string' },
|
|
13
|
+
age: { type: 'integer' }
|
|
14
|
+
},
|
|
15
|
+
required: %w[name age],
|
|
16
|
+
additionalProperties: false
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
each_model(STRUCTURED_OUTPUT_MODELS) do |provider, model|
|
|
21
|
+
it "#{provider}/#{model} returns structured output" do
|
|
22
|
+
response = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true)
|
|
23
|
+
.with_schema(person_schema)
|
|
24
|
+
.ask('Generate a person named John who is 30 years old')
|
|
25
|
+
|
|
26
|
+
expect(response.content).to be_a(String)
|
|
27
|
+
expect(response.parsed).to include('name' => 'John', 'age' => 30)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Chat, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
each_model(CHAT_MODELS) do |provider, model|
|
|
9
|
+
it "#{provider}/#{model} can have a basic conversation" do
|
|
10
|
+
response = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true).ask("What's 2 + 2?")
|
|
11
|
+
|
|
12
|
+
expect(response.content).to include('4')
|
|
13
|
+
expect(response.role).to eq(:assistant)
|
|
14
|
+
expect(response.tokens.input.to_i).to be_positive
|
|
15
|
+
expect(response.tokens.output.to_i).to be_positive
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "#{provider}/#{model} returns the raw response" do
|
|
19
|
+
response = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true)
|
|
20
|
+
.ask('What is the capital of France?')
|
|
21
|
+
|
|
22
|
+
expect(response.raw.status).to eq(200)
|
|
23
|
+
expect(response.raw.headers).not_to be_empty
|
|
24
|
+
expect(response.raw.body).not_to be_empty
|
|
25
|
+
expect(response.raw.env.request_body).not_to be_empty
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "#{provider}/#{model} can handle a multi-turn conversation" do
|
|
29
|
+
chat = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true)
|
|
30
|
+
|
|
31
|
+
expect(chat.ask('Who created the programming language Ruby?').content).to match(/Matz|Matsumoto/i)
|
|
32
|
+
expect(chat.ask('What year was Ruby first released?').content).to include('199')
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Chat, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
each_model(CHAT_MODELS) do |provider, model|
|
|
9
|
+
it "#{provider}/#{model} supports streaming responses" do
|
|
10
|
+
chunks = []
|
|
11
|
+
chat = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true)
|
|
12
|
+
|
|
13
|
+
response = chat.ask('Count from 1 to 3') { |chunk| chunks << chunk }
|
|
14
|
+
|
|
15
|
+
expect(chunks).not_to be_empty
|
|
16
|
+
expect(chunks.first).to be_a(RubyLLM::Chunk)
|
|
17
|
+
expect(response.raw.status).to eq(200)
|
|
18
|
+
expect(response.raw.headers).not_to be_empty
|
|
19
|
+
expect(response.raw.env.request_body).not_to be_empty
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Chat, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
let(:weather_tool) do
|
|
9
|
+
Class.new(RubyLLM::Tool) do
|
|
10
|
+
description 'Gets current weather for a location'
|
|
11
|
+
parameter :latitude, description: 'Latitude'
|
|
12
|
+
parameter :longitude, description: 'Longitude'
|
|
13
|
+
|
|
14
|
+
def execute(latitude:, longitude:)
|
|
15
|
+
"Current weather at #{latitude}, #{longitude}: 15°C, Wind: 10 km/h"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
each_model(TOOL_MODELS) do |provider, model|
|
|
21
|
+
it "#{provider}/#{model} can use tools" do
|
|
22
|
+
chat = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true).with_tools(weather_tool)
|
|
23
|
+
response = chat.ask("What's the weather in Berlin? Use 52.5200, 13.4050.")
|
|
24
|
+
|
|
25
|
+
expect(response.content).to include('15')
|
|
26
|
+
expect(response.content).to include('10')
|
|
27
|
+
expect(chat.messages.any?(&:tool_call?)).to be(true)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Embedding, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
each_model(EMBEDDING_MODELS) do |provider, model, model_info|
|
|
9
|
+
it "#{provider}/#{model} embeds one text" do
|
|
10
|
+
embedding = RubyLLM.embed(
|
|
11
|
+
"Ruby is a programmer's best friend",
|
|
12
|
+
model: model,
|
|
13
|
+
provider: provider,
|
|
14
|
+
assume_model_exists: true
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
expect(embedding.vectors).to be_an(Array)
|
|
18
|
+
expect(embedding.vectors.first).to be_a(Numeric)
|
|
19
|
+
expect(embedding.model).to eq(model)
|
|
20
|
+
expect(embedding.tokens.input.to_i).to be >= 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "#{provider}/#{model} embeds several texts" do
|
|
24
|
+
embeddings = RubyLLM.embed(
|
|
25
|
+
%w[Ruby Python JavaScript],
|
|
26
|
+
model: model,
|
|
27
|
+
provider: provider,
|
|
28
|
+
assume_model_exists: true
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
expect(embeddings.vectors.size).to eq(3)
|
|
32
|
+
expect(embeddings.vectors).to all(be_an(Array))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
next unless model_info[:dimensions]
|
|
36
|
+
|
|
37
|
+
it "#{provider}/#{model} supports custom dimensions" do
|
|
38
|
+
embedding = RubyLLM.embed(
|
|
39
|
+
'Ruby',
|
|
40
|
+
model: model,
|
|
41
|
+
provider: provider,
|
|
42
|
+
assume_model_exists: true,
|
|
43
|
+
dimensions: model_info[:dimensions]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(embedding.vectors.length).to eq(model_info[:dimensions])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Image, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
each_model(IMAGE_GENERATION_MODELS) do |provider, model, model_info|
|
|
9
|
+
it "#{provider}/#{model} paints an image" do
|
|
10
|
+
image = RubyLLM.paint(
|
|
11
|
+
'a siamese cat',
|
|
12
|
+
model: model,
|
|
13
|
+
provider: provider,
|
|
14
|
+
assume_model_exists: true,
|
|
15
|
+
provider_options: model_info.fetch(:provider_options, {})
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
expect(image.mime_type).to include('image')
|
|
19
|
+
expect(image.model).to eq(model)
|
|
20
|
+
expect(image.to_blob.bytesize).to be > 1000
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Models do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
it 'expects generated providers to add registry metadata before use' do
|
|
9
|
+
expect(RubyLLM::Providers::LMS.assume_models_exist?).to be(false)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Moderation, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
each_model(MODERATION_MODELS) do |provider, model|
|
|
9
|
+
it "#{provider}/#{model} moderates content" do
|
|
10
|
+
moderation = RubyLLM.moderate(
|
|
11
|
+
'This is a safe message',
|
|
12
|
+
model: model,
|
|
13
|
+
provider: provider,
|
|
14
|
+
assume_model_exists: true
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
expect(moderation).to be_a(described_class)
|
|
18
|
+
expect(moderation.results).to all(be_a(RubyLLM::Moderation::Result))
|
|
19
|
+
expect(moderation.flagged?).to be_in([true, false])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Providers::LMS do
|
|
6
|
+
subject(:provider) { described_class.new(config) }
|
|
7
|
+
|
|
8
|
+
let(:config) do
|
|
9
|
+
RubyLLM::Configuration.new.tap do |provider_config|
|
|
10
|
+
provider_config.lms_api_base = 'http://example.test:1234/v1'
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'is registered with RubyLLM' do
|
|
15
|
+
expect(RubyLLM::Provider.resolve(:lms)).to eq(described_class)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'registers its protocols' do
|
|
19
|
+
expect(described_class.protocols).to include(
|
|
20
|
+
chat_completions: described_class::ChatCompletions,
|
|
21
|
+
responses: RubyLLM::Protocols::Responses
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'declares provider configuration' do
|
|
26
|
+
expect(described_class.configuration_options).to eq(%i[lms_api_base lms_api_key])
|
|
27
|
+
expect(described_class.configuration_requirements).to eq([])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'is a local provider with a human-readable name' do
|
|
31
|
+
expect(described_class.local?).to be(true)
|
|
32
|
+
expect(described_class.display_name).to eq('LM Studio')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'uses the configured API base' do
|
|
36
|
+
expect(provider.api_base).to eq('http://example.test:1234/v1')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'defaults to the LM Studio server address' do
|
|
40
|
+
provider = described_class.new(RubyLLM::Configuration.new)
|
|
41
|
+
expect(provider.api_base).to eq('http://localhost:1234/v1')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'sends no Authorization header without an API key' do
|
|
45
|
+
expect(provider.headers).to eq({})
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'sends a bearer token when an API key is configured' do
|
|
49
|
+
config.lms_api_key = 'test-key'
|
|
50
|
+
expect(provider.headers).to eq('Authorization' => 'Bearer test-key')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe 'model listing' do
|
|
54
|
+
subject(:protocol) do
|
|
55
|
+
described_class::ChatCompletions.new(described_class.new(config))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
let(:llm_detail) do
|
|
59
|
+
{
|
|
60
|
+
'type' => 'llm',
|
|
61
|
+
'arch' => 'qwen2',
|
|
62
|
+
'publisher' => 'lmstudio-community',
|
|
63
|
+
'compatibility_type' => 'gguf',
|
|
64
|
+
'quantization' => 'Q4_K_M',
|
|
65
|
+
'state' => 'loaded',
|
|
66
|
+
'max_context_length' => 32_768,
|
|
67
|
+
'capabilities' => ['tool_use']
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
let(:vlm_detail) { { 'type' => 'vlm', 'arch' => 'qwen2_vl' } }
|
|
71
|
+
let(:embedding_detail) { { 'type' => 'embeddings', 'arch' => 'nomic-bert' } }
|
|
72
|
+
|
|
73
|
+
describe '#build_modalities' do
|
|
74
|
+
it 'maps chat models to text in and out' do
|
|
75
|
+
expect(protocol.build_modalities(llm_detail)).to eq(input: %w[text], output: %w[text])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'adds image input for vision models' do
|
|
79
|
+
expect(protocol.build_modalities(vlm_detail)).to eq(input: %w[text image], output: %w[text])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'maps embedding models to embedding output' do
|
|
83
|
+
expect(protocol.build_modalities(embedding_detail)).to eq(input: %w[text], output: %w[embeddings])
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe '#build_capabilities' do
|
|
88
|
+
it 'derives function calling from reported tool_use' do
|
|
89
|
+
expect(protocol.build_capabilities(llm_detail))
|
|
90
|
+
.to eq(%w[streaming structured_output function_calling])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'derives vision from the model type' do
|
|
94
|
+
expect(protocol.build_capabilities(vlm_detail)).to include('vision')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'reports no chat capabilities for embedding models' do
|
|
98
|
+
expect(protocol.build_capabilities(embedding_detail)).to eq([])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'falls back to base capabilities without native details' do
|
|
102
|
+
expect(protocol.build_capabilities({})).to eq(%w[streaming structured_output])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe '#build_metadata' do
|
|
107
|
+
it 'keeps native details and drops missing fields' do
|
|
108
|
+
metadata = protocol.build_metadata({ 'owned_by' => 'organization_owner' }, llm_detail)
|
|
109
|
+
expect(metadata).to eq(
|
|
110
|
+
owned_by: 'organization_owner',
|
|
111
|
+
publisher: 'lmstudio-community',
|
|
112
|
+
arch: 'qwen2',
|
|
113
|
+
compatibility_type: 'gguf',
|
|
114
|
+
quantization: 'Q4_K_M',
|
|
115
|
+
state: 'loaded'
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'compacts unknown details away' do
|
|
120
|
+
expect(protocol.build_metadata({ 'owned_by' => 'organization_owner' }, {}))
|
|
121
|
+
.to eq(owned_by: 'organization_owner')
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
describe '#parse_list_models_response' do
|
|
126
|
+
it 'builds enriched models from the listing and native details' do
|
|
127
|
+
response = instance_double(Faraday::Response,
|
|
128
|
+
body: { 'data' => [{ 'id' => 'qwen2.5-7b-instruct',
|
|
129
|
+
'owned_by' => 'organization_owner' }] })
|
|
130
|
+
models = protocol.parse_list_models_response(response, 'lms',
|
|
131
|
+
details: { 'qwen2.5-7b-instruct' => llm_detail })
|
|
132
|
+
expect(models.length).to eq(1)
|
|
133
|
+
model = models.first
|
|
134
|
+
expect(model.id).to eq('qwen2.5-7b-instruct')
|
|
135
|
+
expect(model.provider).to eq('lms')
|
|
136
|
+
expect(model.family).to eq('qwen2')
|
|
137
|
+
expect(model.context_window).to eq(32_768)
|
|
138
|
+
expect(model.capabilities).to include('function_calling')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'builds plain models when native details are unavailable' do
|
|
142
|
+
response = instance_double(Faraday::Response, body: { 'data' => [{ 'id' => 'some-model' }] })
|
|
143
|
+
model = protocol.parse_list_models_response(response, 'lms').first
|
|
144
|
+
expect(model.family).to eq('lms')
|
|
145
|
+
expect(model.capabilities).to eq(%w[streaming structured_output])
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Rerank, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
each_model(RERANK_MODELS) do |provider, model|
|
|
9
|
+
it "#{provider}/#{model} orders documents by relevance" do
|
|
10
|
+
rerank = RubyLLM.rerank(
|
|
11
|
+
'What is the capital of the United States?',
|
|
12
|
+
['Carson City is the capital of Nevada.', 'Washington, D.C. is the capital of the United States.'],
|
|
13
|
+
model: model,
|
|
14
|
+
provider: provider,
|
|
15
|
+
assume_model_exists: true
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
expect(rerank.results.first.document).to include('Washington')
|
|
19
|
+
expect(rerank.results.first.score).to be > rerank.results.last.score
|
|
20
|
+
expect(rerank.results.map(&:index)).to contain_exactly(0, 1)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Speech, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
each_model(SPEECH_MODELS) do |provider, model, model_info|
|
|
9
|
+
it "#{provider}/#{model} speaks" do
|
|
10
|
+
speech = RubyLLM.speak(
|
|
11
|
+
'Ruby is a programming language designed for developer happiness.',
|
|
12
|
+
model: model,
|
|
13
|
+
provider: provider,
|
|
14
|
+
assume_model_exists: true,
|
|
15
|
+
voice: model_info[:voice],
|
|
16
|
+
format: model_info[:format]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
expect(speech.data).to be_a(String)
|
|
20
|
+
expect(speech.data.bytesize).to be > 1000
|
|
21
|
+
expect(speech.model).to eq(model)
|
|
22
|
+
expect(speech.mime_type).to start_with('audio/')
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Video, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
RubyLLM.config.video_generation_poll_interval = VCR.current_cassette&.recording? ? 5 : 0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
each_model(VIDEO_GENERATION_MODELS) do |provider, model, model_info|
|
|
13
|
+
it "#{provider}/#{model} animates a video" do
|
|
14
|
+
video = RubyLLM.animate(
|
|
15
|
+
'a calm ocean wave at sunset',
|
|
16
|
+
model: model,
|
|
17
|
+
provider: provider,
|
|
18
|
+
assume_model_exists: true,
|
|
19
|
+
provider_options: model_info.fetch(:provider_options, {})
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(video.mime_type).to include('video')
|
|
23
|
+
expect(video.url || video.data).not_to be_nil
|
|
24
|
+
expect(video.to_blob.bytesize).to be > 10_000
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'dotenv/load'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
require 'vcr'
|
|
8
|
+
require 'ruby_llm/providers/lms'
|
|
9
|
+
require 'webmock/rspec'
|
|
10
|
+
|
|
11
|
+
Dir[File.expand_path('support/**/*.rb', __dir__)].each { |file| require file }
|
|
12
|
+
|
|
13
|
+
RSpec.configure do |config|
|
|
14
|
+
config.disable_monkey_patching!
|
|
15
|
+
config.expect_with(:rspec) { |expectations| expectations.syntax = :expect }
|
|
16
|
+
config.order = :random
|
|
17
|
+
Kernel.srand config.seed
|
|
18
|
+
|
|
19
|
+
config.around(:each, :live) do |example|
|
|
20
|
+
cassette_name = example.full_description.downcase.gsub(/[^a-z0-9]+/, '_').delete_prefix('_').delete_suffix('_')
|
|
21
|
+
cassette_path = File.join(VCR.configuration.cassette_library_dir, "#{cassette_name}.yml")
|
|
22
|
+
|
|
23
|
+
VCR.use_cassette(cassette_name) { example.run }
|
|
24
|
+
FileUtils.rm_f(cassette_path) if example.exception
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Model matrices for the live contract specs. LM Studio serves whatever
|
|
4
|
+
# models are downloaded on this machine, so these ids are machine-specific;
|
|
5
|
+
# swap in ids from `lms ls` (or models.json) when recording cassettes.
|
|
6
|
+
# See RubyLLM's full live matrix and specs: https://github.com/crmne/ruby_llm/tree/main/spec
|
|
7
|
+
PROVIDER = :lms
|
|
8
|
+
CHAT_MODELS = [
|
|
9
|
+
{ provider: PROVIDER, model: 'openai/gpt-oss-20b' }
|
|
10
|
+
].freeze
|
|
11
|
+
TOOL_MODELS = CHAT_MODELS
|
|
12
|
+
# gpt-oss models mangle json_schema values on LM Studio (harmony format);
|
|
13
|
+
# qwen3 models honor it.
|
|
14
|
+
STRUCTURED_OUTPUT_MODELS = [
|
|
15
|
+
{ provider: PROVIDER, model: 'qwen3-0.6b-bible-assistant' }
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
EMBEDDING_MODELS = [
|
|
19
|
+
{ provider: PROVIDER, model: 'text-embedding-nomic-embed-text-v1.5' }
|
|
20
|
+
].freeze
|
|
21
|
+
IMAGE_GENERATION_MODELS = [].freeze
|
|
22
|
+
SPEECH_MODELS = [].freeze
|
|
23
|
+
VIDEO_GENERATION_MODELS = [].freeze
|
|
24
|
+
MODERATION_MODELS = [].freeze
|
|
25
|
+
RERANK_MODELS = [].freeze
|
|
26
|
+
|
|
27
|
+
def each_model(models)
|
|
28
|
+
models.each { |model_info| yield model_info[:provider], model_info[:model], model_info }
|
|
29
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.shared_context 'with configured RubyLLM' do
|
|
4
|
+
before do
|
|
5
|
+
RubyLLM.configure do |config|
|
|
6
|
+
config.lms_api_key = ENV.fetch('LMS_API_KEY', 'test')
|
|
7
|
+
config.lms_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
|
|
8
|
+
config.max_retries = 0
|
|
9
|
+
config.retry_backoff_factor = 0
|
|
10
|
+
config.retry_interval = 0
|
|
11
|
+
config.retry_interval_randomness = 0
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
VCR.configure do |config|
|
|
4
|
+
config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
|
|
5
|
+
config.hook_into :webmock
|
|
6
|
+
config.default_cassette_options = { record: ENV['CI'] ? :none : :once }
|
|
7
|
+
config.allow_http_connections_when_no_cassette = true
|
|
8
|
+
config.filter_sensitive_data('<LMS_API_KEY>') { ENV.fetch('LMS_API_KEY', nil) }
|
|
9
|
+
config.filter_sensitive_data('<LMS_API_BASE>') { ENV.fetch('LMS_API_BASE', nil) }
|
|
10
|
+
|
|
11
|
+
config.before_record do |interaction|
|
|
12
|
+
next unless interaction.request.headers['Authorization']
|
|
13
|
+
|
|
14
|
+
interaction.request.headers['Authorization'] = ['Bearer <AUTH_TOKEN>']
|
|
15
|
+
end
|
|
16
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_llm-providers-lms
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- madbomber
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ruby_llm
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 2.0.0.rc1
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 2.0.0.rc1
|
|
26
|
+
description: Adds LM Studio provider support to RubyLLM via LM Studio's OpenAI-compatible
|
|
27
|
+
local server, with model listings enriched from LM Studio's native REST API.
|
|
28
|
+
email:
|
|
29
|
+
- dvanhoozer@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- ".flayignore"
|
|
35
|
+
- ".github/workflows/ci.yml"
|
|
36
|
+
- ".github/workflows/gitleaks.yml"
|
|
37
|
+
- ".github/workflows/release.yml"
|
|
38
|
+
- ".overcommit.yml"
|
|
39
|
+
- ".rspec"
|
|
40
|
+
- ".rubocop.yml"
|
|
41
|
+
- Archspec.rb
|
|
42
|
+
- LICENSE
|
|
43
|
+
- README.md
|
|
44
|
+
- lib/ruby_llm/providers/lms.rb
|
|
45
|
+
- lib/ruby_llm/providers/lms/models.rb
|
|
46
|
+
- models.json
|
|
47
|
+
- spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_handle_a_multi_turn_conversation.yml
|
|
48
|
+
- spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_have_a_basic_conversation.yml
|
|
49
|
+
- spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_can_use_tools.yml
|
|
50
|
+
- spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_returns_the_raw_response.yml
|
|
51
|
+
- spec/fixtures/vcr_cassettes/rubyllm_chat_lms_openai_gpt_oss_20b_supports_streaming_responses.yml
|
|
52
|
+
- spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_can_have_a_basic_conversation.yml
|
|
53
|
+
- spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_returns_structured_output.yml
|
|
54
|
+
- spec/fixtures/vcr_cassettes/rubyllm_chat_lms_qwen3_0_6b_bible_assistant_returns_the_raw_response.yml
|
|
55
|
+
- spec/fixtures/vcr_cassettes/rubyllm_embedding_lms_text_embedding_nomic_embed_text_v1_5_embeds_one_text.yml
|
|
56
|
+
- spec/fixtures/vcr_cassettes/rubyllm_embedding_lms_text_embedding_nomic_embed_text_v1_5_embeds_several_texts.yml
|
|
57
|
+
- spec/ruby_llm/chat_schema_spec.rb
|
|
58
|
+
- spec/ruby_llm/chat_spec.rb
|
|
59
|
+
- spec/ruby_llm/chat_streaming_spec.rb
|
|
60
|
+
- spec/ruby_llm/chat_tools_spec.rb
|
|
61
|
+
- spec/ruby_llm/embedding_spec.rb
|
|
62
|
+
- spec/ruby_llm/image_spec.rb
|
|
63
|
+
- spec/ruby_llm/models_spec.rb
|
|
64
|
+
- spec/ruby_llm/moderation_spec.rb
|
|
65
|
+
- spec/ruby_llm/providers/lms_spec.rb
|
|
66
|
+
- spec/ruby_llm/rerank_spec.rb
|
|
67
|
+
- spec/ruby_llm/speech_spec.rb
|
|
68
|
+
- spec/ruby_llm/video_spec.rb
|
|
69
|
+
- spec/spec_helper.rb
|
|
70
|
+
- spec/support/models.rb
|
|
71
|
+
- spec/support/rubyllm_configuration.rb
|
|
72
|
+
- spec/support/vcr_configuration.rb
|
|
73
|
+
homepage: https://github.com/madbomber/ruby_llm-providers-lms
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
homepage_uri: https://github.com/madbomber/ruby_llm-providers-lms
|
|
78
|
+
source_code_uri: https://github.com/madbomber/ruby_llm-providers-lms
|
|
79
|
+
changelog_uri: https://github.com/madbomber/ruby_llm-providers-lms/releases
|
|
80
|
+
bug_tracker_uri: https://github.com/madbomber/ruby_llm-providers-lms/issues
|
|
81
|
+
rubygems_mfa_required: 'true'
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.1'
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 4.0.20
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: RubyLLM provider for LM Studio.
|
|
99
|
+
test_files: []
|