ruby_llm-red_candle 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/.rspec +3 -0
- data/.rubocop.yml +39 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +378 -0
- data/Rakefile +10 -0
- data/examples/smoke_test.rb +320 -0
- data/lib/ruby_llm/red_candle/capabilities.rb +112 -0
- data/lib/ruby_llm/red_candle/chat.rb +445 -0
- data/lib/ruby_llm/red_candle/configuration.rb +38 -0
- data/lib/ruby_llm/red_candle/models.rb +120 -0
- data/lib/ruby_llm/red_candle/provider.rb +92 -0
- data/lib/ruby_llm/red_candle/schema_validator.rb +102 -0
- data/lib/ruby_llm/red_candle/streaming.rb +38 -0
- data/lib/ruby_llm/red_candle/version.rb +7 -0
- data/lib/ruby_llm-red_candle.rb +32 -0
- metadata +172 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Smoke test for RubyLLM::RedCandle
|
|
5
|
+
# Run with: bundle exec ruby examples/smoke_test.rb
|
|
6
|
+
#
|
|
7
|
+
# This script demonstrates all the features documented in the README.
|
|
8
|
+
# It uses TinyLlama by default as it's the fastest model to download and run.
|
|
9
|
+
|
|
10
|
+
require "bundler/setup"
|
|
11
|
+
require "ruby_llm"
|
|
12
|
+
require "ruby_llm-red_candle"
|
|
13
|
+
|
|
14
|
+
# Configuration
|
|
15
|
+
MODEL = ENV.fetch("SMOKE_TEST_MODEL", "TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF")
|
|
16
|
+
SKIP_SLOW = ENV.fetch("SKIP_SLOW", nil) # Set to skip multi-turn and streaming tests
|
|
17
|
+
|
|
18
|
+
# Helper for printing section headers
|
|
19
|
+
def section(title)
|
|
20
|
+
puts
|
|
21
|
+
puts "=" * 60
|
|
22
|
+
puts " #{title}"
|
|
23
|
+
puts "=" * 60
|
|
24
|
+
puts
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Helper for printing test results
|
|
28
|
+
def result(label, value)
|
|
29
|
+
puts " #{label}: #{value}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def success(message)
|
|
33
|
+
puts " [OK] #{message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def info(message)
|
|
37
|
+
puts " [INFO] #{message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
section "RubyLLM::RedCandle Smoke Test"
|
|
42
|
+
info "Model: #{MODEL}"
|
|
43
|
+
info "Ruby: #{RUBY_VERSION}"
|
|
44
|
+
info "RubyLLM: #{RubyLLM::VERSION}"
|
|
45
|
+
info "RedCandle: #{RubyLLM::RedCandle::VERSION}"
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
section "1. Listing Available Models"
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
models = RubyLLM.models.all.select { |m| m.provider == "red_candle" }
|
|
52
|
+
puts " Available Red Candle models:"
|
|
53
|
+
models.each do |m|
|
|
54
|
+
puts " - #{m.id} (#{m.context_window} tokens)"
|
|
55
|
+
end
|
|
56
|
+
success "Found #{models.size} models"
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
section "2. Basic Chat (Non-Streaming)"
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
info "Creating chat instance..."
|
|
63
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
64
|
+
success "Chat created"
|
|
65
|
+
|
|
66
|
+
info "Asking: 'What is 2 + 2?'"
|
|
67
|
+
response = chat.ask("What is 2 + 2? Answer with just the number.")
|
|
68
|
+
|
|
69
|
+
result "Response", response.content.strip
|
|
70
|
+
result "Input tokens (estimated)", response.input_tokens
|
|
71
|
+
result "Output tokens (estimated)", response.output_tokens
|
|
72
|
+
success "Basic chat completed"
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
section "3. Multi-Turn Conversation"
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
unless SKIP_SLOW
|
|
79
|
+
info "Creating new chat for multi-turn..."
|
|
80
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
81
|
+
|
|
82
|
+
info "Turn 1: 'My name is Alice.'"
|
|
83
|
+
chat.ask("My name is Alice.")
|
|
84
|
+
|
|
85
|
+
info "Turn 2: 'I love programming in Ruby.'"
|
|
86
|
+
chat.ask("I love programming in Ruby.")
|
|
87
|
+
|
|
88
|
+
info "Turn 3: 'What is my name and what do I love?'"
|
|
89
|
+
response = chat.ask("What is my name and what do I love? Be brief.")
|
|
90
|
+
|
|
91
|
+
result "Response", response.content.strip[0..200]
|
|
92
|
+
success "Multi-turn conversation completed"
|
|
93
|
+
else
|
|
94
|
+
info "Skipped (SKIP_SLOW is set)"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
section "4. Streaming Output"
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
unless SKIP_SLOW
|
|
102
|
+
info "Creating chat for streaming..."
|
|
103
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
104
|
+
|
|
105
|
+
info "Streaming response for: 'Count from 1 to 5'"
|
|
106
|
+
print " Response: "
|
|
107
|
+
|
|
108
|
+
chunks = []
|
|
109
|
+
chat.ask("Count from 1 to 5, just list the numbers.") do |chunk|
|
|
110
|
+
print chunk.content
|
|
111
|
+
$stdout.flush
|
|
112
|
+
chunks << chunk
|
|
113
|
+
end
|
|
114
|
+
puts
|
|
115
|
+
|
|
116
|
+
result "Chunks received", chunks.size
|
|
117
|
+
success "Streaming completed"
|
|
118
|
+
else
|
|
119
|
+
info "Skipped (SKIP_SLOW is set)"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
section "5. Temperature Control"
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
info "Testing low temperature (0.1) - more deterministic..."
|
|
127
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
128
|
+
response = chat.with_temperature(0.1).ask("What is the capital of France? One word answer.")
|
|
129
|
+
result "Low temp response", response.content.strip
|
|
130
|
+
|
|
131
|
+
info "Testing high temperature (1.5) - more creative..."
|
|
132
|
+
response = chat.with_temperature(1.5).ask("Say something creative about the moon in 10 words or less.")
|
|
133
|
+
result "High temp response", response.content.strip
|
|
134
|
+
|
|
135
|
+
success "Temperature control completed"
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
section "6. System Prompts (Instructions)"
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
info "Setting system prompt for a pirate assistant..."
|
|
142
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
143
|
+
chat.with_instructions("You are a pirate. Always respond like a pirate would, using pirate slang.")
|
|
144
|
+
|
|
145
|
+
response = chat.ask("Hello, how are you today?")
|
|
146
|
+
result "Pirate response", response.content.strip[0..150]
|
|
147
|
+
success "System prompts completed"
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
section "7. Structured Output (JSON Schema)"
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
info "Defining schema for person profile..."
|
|
154
|
+
schema = {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
name: { type: "string" },
|
|
158
|
+
age: { type: "integer" },
|
|
159
|
+
occupation: { type: "string" }
|
|
160
|
+
},
|
|
161
|
+
required: %w[name age occupation]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
info "Generating structured output..."
|
|
165
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
166
|
+
response = chat.with_schema(schema).ask("Generate a profile for a 28-year-old teacher named Bob")
|
|
167
|
+
|
|
168
|
+
result "Response type", response.content.class
|
|
169
|
+
result "Response", response.content.inspect
|
|
170
|
+
|
|
171
|
+
if response.content.is_a?(Hash)
|
|
172
|
+
result "Name", response.content["name"]
|
|
173
|
+
result "Age", response.content["age"]
|
|
174
|
+
result "Occupation", response.content["occupation"]
|
|
175
|
+
success "Structured output returned a Hash"
|
|
176
|
+
else
|
|
177
|
+
info "Response was a String (may indicate parse issue): #{response.content}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
section "8. Structured Output with Enums"
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
info "Defining schema with enum constraint..."
|
|
185
|
+
sentiment_schema = {
|
|
186
|
+
type: "object",
|
|
187
|
+
properties: {
|
|
188
|
+
sentiment: {
|
|
189
|
+
type: "string",
|
|
190
|
+
enum: %w[positive negative neutral]
|
|
191
|
+
},
|
|
192
|
+
confidence: { type: "number" }
|
|
193
|
+
},
|
|
194
|
+
required: %w[sentiment confidence]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
info "Analyzing sentiment..."
|
|
198
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
199
|
+
response = chat.with_schema(sentiment_schema).ask("Analyze: 'I absolutely love this product!'")
|
|
200
|
+
|
|
201
|
+
result "Response", response.content.inspect
|
|
202
|
+
|
|
203
|
+
if response.content.is_a?(Hash)
|
|
204
|
+
result "Sentiment", response.content["sentiment"]
|
|
205
|
+
result "Confidence", response.content["confidence"]
|
|
206
|
+
success "Enum-constrained output completed"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
section "9. Structured Output with String Keys (Normalization)"
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
info "Testing schema with string keys (should be normalized to symbols)..."
|
|
214
|
+
string_key_schema = {
|
|
215
|
+
"type" => "object",
|
|
216
|
+
"properties" => {
|
|
217
|
+
"answer" => { "type" => "string" }
|
|
218
|
+
},
|
|
219
|
+
"required" => ["answer"]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
223
|
+
response = chat.with_schema(string_key_schema).ask("What is 3 + 3?")
|
|
224
|
+
|
|
225
|
+
result "Response", response.content.inspect
|
|
226
|
+
success "String key normalization completed"
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
section "10. Schema Validation"
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
info "Testing schema validation with invalid schema..."
|
|
233
|
+
begin
|
|
234
|
+
invalid_schema = { type: "object" } # Missing 'properties'
|
|
235
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
236
|
+
chat.with_schema(invalid_schema).ask("This should fail")
|
|
237
|
+
puts " [FAIL] Should have raised a validation error"
|
|
238
|
+
rescue RubyLLM::Error => e
|
|
239
|
+
result "Validation error caught", e.message.lines.first.strip
|
|
240
|
+
success "Schema validation works correctly"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
info "Testing with non-object schema type..."
|
|
244
|
+
begin
|
|
245
|
+
array_schema = { type: "array", items: { type: "string" } }
|
|
246
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
247
|
+
chat.with_schema(array_schema).ask("This should fail")
|
|
248
|
+
puts " [FAIL] Should have raised a validation error"
|
|
249
|
+
rescue RubyLLM::Error => e
|
|
250
|
+
result "Type error caught", e.message.lines.first.strip
|
|
251
|
+
success "Non-object schema rejection works correctly"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
section "11. Custom JSON Instruction Template"
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
info "Current default template:"
|
|
259
|
+
result "Default", RubyLLM::RedCandle::Configuration.json_instruction_template[0..60] + "..."
|
|
260
|
+
|
|
261
|
+
info "Setting custom template..."
|
|
262
|
+
RubyLLM::RedCandle::Configuration.json_instruction_template = <<~TEMPLATE
|
|
263
|
+
|
|
264
|
+
OUTPUT FORMAT: You must respond with valid JSON containing: {schema_description}
|
|
265
|
+
No other text allowed, only the JSON object.
|
|
266
|
+
TEMPLATE
|
|
267
|
+
|
|
268
|
+
result "Custom template set", RubyLLM::RedCandle::Configuration.json_instruction_template.lines.first.strip
|
|
269
|
+
|
|
270
|
+
info "Testing structured output with custom template..."
|
|
271
|
+
chat = RubyLLM.chat(provider: :red_candle, model: MODEL)
|
|
272
|
+
custom_schema = {
|
|
273
|
+
type: "object",
|
|
274
|
+
properties: {
|
|
275
|
+
greeting: { type: "string" }
|
|
276
|
+
},
|
|
277
|
+
required: ["greeting"]
|
|
278
|
+
}
|
|
279
|
+
response = chat.with_schema(custom_schema).ask("Say hello")
|
|
280
|
+
|
|
281
|
+
result "Response", response.content.inspect
|
|
282
|
+
success "Custom template structured output completed"
|
|
283
|
+
|
|
284
|
+
info "Resetting to default template..."
|
|
285
|
+
RubyLLM::RedCandle::Configuration.reset!
|
|
286
|
+
result "Template reset", RubyLLM::RedCandle::Configuration.json_instruction_template[0..60] + "..."
|
|
287
|
+
success "Configuration reset works correctly"
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
section "12. Error Handling"
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
info "Testing error handling with invalid model..."
|
|
294
|
+
begin
|
|
295
|
+
RubyLLM.chat(provider: :red_candle, model: "invalid/nonexistent-model")
|
|
296
|
+
puts " [FAIL] Should have raised an error"
|
|
297
|
+
rescue RubyLLM::Error => e
|
|
298
|
+
result "Error caught", e.message[0..60]
|
|
299
|
+
success "Error handling works correctly"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
section "Summary"
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
puts " All smoke tests completed successfully!"
|
|
307
|
+
puts
|
|
308
|
+
puts " To run with a different model:"
|
|
309
|
+
puts " SMOKE_TEST_MODEL='Qwen/Qwen2.5-1.5B-Instruct-GGUF' bundle exec ruby examples/smoke_test.rb"
|
|
310
|
+
puts
|
|
311
|
+
puts " To skip slow tests:"
|
|
312
|
+
puts " SKIP_SLOW=1 bundle exec ruby examples/smoke_test.rb"
|
|
313
|
+
puts
|
|
314
|
+
|
|
315
|
+
rescue StandardError => e
|
|
316
|
+
puts
|
|
317
|
+
puts " [ERROR] #{e.class}: #{e.message}"
|
|
318
|
+
puts e.backtrace.first(5).map { |line| " #{line}" }.join("\n")
|
|
319
|
+
exit 1
|
|
320
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module RedCandle
|
|
5
|
+
# Determines capabilities for RedCandle models
|
|
6
|
+
module Capabilities
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def supports_vision?
|
|
10
|
+
false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def supports_functions?(_model_id = nil)
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def supports_streaming?
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def supports_structured_output?
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def supports_regex_constraints?
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def supports_embeddings?
|
|
30
|
+
false # Future enhancement - Red Candle does support embedding models
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def supports_audio?
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def supports_pdf?
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_temperature(temperature, _model_id)
|
|
42
|
+
# Red Candle uses standard 0-2 range
|
|
43
|
+
return 0.7 if temperature.nil?
|
|
44
|
+
|
|
45
|
+
temperature = temperature.to_f
|
|
46
|
+
temperature.clamp(0.0, 2.0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def model_context_window(model_id)
|
|
50
|
+
case model_id
|
|
51
|
+
when /gemma-3-4b/i
|
|
52
|
+
8192
|
|
53
|
+
when /qwen2\.5-1\.5b/i, /mistral-7b/i
|
|
54
|
+
32_768
|
|
55
|
+
when /tinyllama/i
|
|
56
|
+
2048
|
|
57
|
+
else
|
|
58
|
+
4096 # Conservative default
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def default_max_tokens
|
|
63
|
+
512
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def max_temperature
|
|
67
|
+
2.0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def min_temperature
|
|
71
|
+
0.0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def supports_temperature?
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def supports_top_p?
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def supports_top_k?
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def supports_repetition_penalty?
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def supports_seed?
|
|
91
|
+
true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def supports_stop_sequences?
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def model_families
|
|
99
|
+
%w[gemma llama qwen2 mistral phi]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def available_on_platform?
|
|
103
|
+
# Check if Candle can be loaded
|
|
104
|
+
|
|
105
|
+
require "candle"
|
|
106
|
+
true
|
|
107
|
+
rescue LoadError
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|