boxcars 0.7.6 → 0.8.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -3
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +41 -0
  5. data/Gemfile +3 -13
  6. data/Gemfile.lock +29 -25
  7. data/POSTHOG_TEST_README.md +118 -0
  8. data/README.md +305 -0
  9. data/boxcars.gemspec +1 -2
  10. data/lib/boxcars/boxcar/active_record.rb +9 -10
  11. data/lib/boxcars/boxcar/calculator.rb +2 -2
  12. data/lib/boxcars/boxcar/engine_boxcar.rb +4 -4
  13. data/lib/boxcars/boxcar/google_search.rb +2 -2
  14. data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
  15. data/lib/boxcars/boxcar/ruby_calculator.rb +1 -1
  16. data/lib/boxcars/boxcar/sql_base.rb +4 -4
  17. data/lib/boxcars/boxcar/swagger.rb +3 -3
  18. data/lib/boxcars/boxcar/vector_answer.rb +3 -3
  19. data/lib/boxcars/boxcar/xml_engine_boxcar.rb +1 -1
  20. data/lib/boxcars/boxcar.rb +6 -6
  21. data/lib/boxcars/conversation_prompt.rb +3 -3
  22. data/lib/boxcars/engine/anthropic.rb +121 -23
  23. data/lib/boxcars/engine/cerebras.rb +2 -2
  24. data/lib/boxcars/engine/cohere.rb +135 -9
  25. data/lib/boxcars/engine/gemini_ai.rb +151 -76
  26. data/lib/boxcars/engine/google.rb +2 -2
  27. data/lib/boxcars/engine/gpt4all_eng.rb +92 -34
  28. data/lib/boxcars/engine/groq.rb +124 -73
  29. data/lib/boxcars/engine/intelligence_base.rb +52 -17
  30. data/lib/boxcars/engine/ollama.rb +127 -47
  31. data/lib/boxcars/engine/openai.rb +186 -103
  32. data/lib/boxcars/engine/perplexityai.rb +116 -136
  33. data/lib/boxcars/engine/together.rb +2 -2
  34. data/lib/boxcars/engine/unified_observability.rb +430 -0
  35. data/lib/boxcars/engine.rb +4 -3
  36. data/lib/boxcars/engines.rb +74 -0
  37. data/lib/boxcars/observability.rb +44 -0
  38. data/lib/boxcars/observability_backend.rb +17 -0
  39. data/lib/boxcars/observability_backends/multi_backend.rb +42 -0
  40. data/lib/boxcars/observability_backends/posthog_backend.rb +89 -0
  41. data/lib/boxcars/observation.rb +8 -8
  42. data/lib/boxcars/prompt.rb +16 -4
  43. data/lib/boxcars/result.rb +7 -12
  44. data/lib/boxcars/ruby_repl.rb +1 -1
  45. data/lib/boxcars/train/train_action.rb +1 -1
  46. data/lib/boxcars/train/xml_train.rb +3 -3
  47. data/lib/boxcars/train/xml_zero_shot.rb +1 -1
  48. data/lib/boxcars/train/zero_shot.rb +3 -3
  49. data/lib/boxcars/train.rb +1 -1
  50. data/lib/boxcars/vector_search.rb +5 -5
  51. data/lib/boxcars/vector_store/pgvector/build_from_array.rb +116 -88
  52. data/lib/boxcars/vector_store/pgvector/build_from_files.rb +106 -80
  53. data/lib/boxcars/vector_store/pgvector/save_to_database.rb +148 -122
  54. data/lib/boxcars/vector_store/pgvector/search.rb +157 -131
  55. data/lib/boxcars/vector_store.rb +4 -4
  56. data/lib/boxcars/version.rb +1 -1
  57. data/lib/boxcars.rb +31 -20
  58. metadata +11 -21
@@ -0,0 +1,430 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module Boxcars
7
+ # Unified observability module that provides PostHog-centric tracking for all engines
8
+ # Uses standardized $ai_* properties as defined by PostHog's LLM observability spec
9
+ # rubocop:disable Metrics/ModuleLength
10
+ module UnifiedObservability
11
+ private
12
+
13
+ # Main tracking method that all engines should call
14
+ def track_ai_generation(duration_ms:, current_params:, request_context:, response_data:, provider:)
15
+ properties = build_unified_observability_properties(
16
+ duration_ms:,
17
+ current_params:,
18
+ request_context:,
19
+ response_data:,
20
+ provider:
21
+ )
22
+ Boxcars::Observability.track(event: '$ai_generation', properties: properties.compact)
23
+ end
24
+
25
+ # Build standardized PostHog properties for any engine
26
+ def build_unified_observability_properties(duration_ms:, current_params:, request_context:, response_data:, provider:)
27
+ # Convert duration from milliseconds to seconds for PostHog
28
+ duration_seconds = duration_ms / 1000.0
29
+
30
+ # Format input messages for PostHog
31
+ ai_input = extract_ai_input(request_context, provider)
32
+
33
+ # Extract token counts from response if available
34
+ input_tokens = extract_input_tokens(response_data, provider)
35
+ output_tokens = extract_output_tokens(response_data, provider)
36
+
37
+ # Format output choices for PostHog
38
+ ai_output_choices = extract_output_choices(response_data, provider)
39
+
40
+ # Generate a trace ID (PostHog requires this)
41
+ trace_id = SecureRandom.uuid
42
+
43
+ properties = {
44
+ # PostHog standard LLM observability properties
45
+ '$ai_trace_id': trace_id,
46
+ '$ai_model': extract_model_name(current_params, provider),
47
+ '$ai_provider': provider.to_s,
48
+ '$ai_input': ai_input.to_json,
49
+ '$ai_input_tokens': input_tokens,
50
+ '$ai_output_choices': ai_output_choices.to_json,
51
+ '$ai_output_tokens': output_tokens,
52
+ '$ai_latency': duration_seconds,
53
+ '$ai_http_status': extract_status_code(response_data) || (response_data[:success] ? 200 : 500),
54
+ '$ai_base_url': get_base_url_for_provider(provider),
55
+ '$ai_is_error': !response_data[:success]
56
+ }
57
+
58
+ # Add error details if present
59
+ properties[:$ai_error] = extract_error_message(response_data, provider) if response_data[:error] || !response_data[:success]
60
+
61
+ properties
62
+ end
63
+
64
+ # Provider-specific input extraction with fallbacks
65
+ def extract_ai_input(request_context, provider)
66
+ case provider.to_s
67
+ when 'openai', 'ollama', 'gemini', 'groq', 'perplexity_ai'
68
+ extract_openai_style_input(request_context)
69
+ when 'anthropic'
70
+ extract_anthropic_input(request_context)
71
+ when 'cohere'
72
+ extract_cohere_input(request_context)
73
+ else
74
+ extract_generic_input(request_context)
75
+ end
76
+ end
77
+
78
+ # Handles OpenAI-style providers (openai, ollama, gemini, groq, perplexity_ai)
79
+ # All use the same message array format and prompt handling logic
80
+ def extract_openai_style_input(request_context)
81
+ if request_context[:conversation_for_api].is_a?(Array)
82
+ request_context[:conversation_for_api]
83
+ else
84
+ # Handle case where prompt might be nil or format might fail
85
+ begin
86
+ formatted_prompt = request_context[:prompt]&.format(request_context[:inputs] || {})
87
+ [{ role: "user", content: formatted_prompt || "" }]
88
+ rescue
89
+ # If prompt formatting fails, try to get the template or convert to string
90
+ prompt_text = if request_context[:prompt].respond_to?(:template)
91
+ request_context[:prompt].template
92
+ else
93
+ request_context[:prompt].to_s
94
+ end
95
+ [{ role: "user", content: prompt_text || "" }]
96
+ end
97
+ end
98
+ end
99
+
100
+ def extract_anthropic_input(request_context)
101
+ content = []
102
+
103
+ # Add system prompt if present
104
+ system_prompt = request_context.dig(:conversation_for_api, :system)
105
+ content << { role: "system", content: system_prompt } if system_prompt && !system_prompt.to_s.strip.empty?
106
+
107
+ # Add messages
108
+ messages = request_context.dig(:conversation_for_api, :messages)
109
+ if messages.is_a?(Array)
110
+ messages.each do |msg|
111
+ content << { role: msg[:role], content: extract_message_content(msg[:content]) }
112
+ end
113
+ elsif request_context[:prompt]
114
+ prompt_text = if request_context[:prompt].respond_to?(:template)
115
+ request_context[:prompt].template
116
+ else
117
+ request_context[:prompt].to_s
118
+ end
119
+ content << { role: "user", content: prompt_text }
120
+ end
121
+
122
+ content
123
+ end
124
+
125
+ def get_prompt_text(prompt, inputs)
126
+ if prompt.respond_to?(:format)
127
+ begin
128
+ prompt.format(inputs || {})
129
+ rescue
130
+ # If prompt formatting fails, fall back to template or string
131
+ prompt.respond_to?(:template) ? prompt.template : prompt.to_s
132
+ end
133
+ elsif prompt.respond_to?(:template)
134
+ prompt.template
135
+ else
136
+ prompt.to_s
137
+ end
138
+ end
139
+
140
+ def extract_cohere_input(request_context)
141
+ # Cohere uses a single message field
142
+ message_content = request_context.dig(:conversation_for_api, :message)
143
+ if message_content
144
+ [{ role: "user", content: message_content }]
145
+ elsif request_context[:prompt]
146
+ # Format the prompt with inputs if available
147
+ prompt_text = get_prompt_text(request_context[:prompt], request_context[:inputs])
148
+ [{ role: "user", content: prompt_text || "" }]
149
+ else
150
+ []
151
+ end
152
+ end
153
+
154
+ def extract_generic_input(request_context)
155
+ # Handle different conversation_for_api formats
156
+ conversation_for_api = request_context[:conversation_for_api]
157
+
158
+ # If it's a string (like GPT4All), create a simple user message
159
+ return [{ role: "user", content: conversation_for_api }] if conversation_for_api.is_a?(String)
160
+
161
+ # For IntelligenceBase-style engines with messages method
162
+ conv_messages = conversation_for_api&.messages if conversation_for_api.respond_to?(:messages)
163
+ return [{ role: "user", content: request_context[:prompt].to_s }] unless conv_messages
164
+
165
+ conv_messages.map do |message|
166
+ content_text = extract_message_content(message)
167
+ { role: message.role, content: content_text }
168
+ end
169
+ end
170
+
171
+ def extract_message_content(content)
172
+ case content
173
+ when String
174
+ content
175
+ when Array
176
+ content.map { |part| part.is_a?(Hash) ? part[:text] || part.to_s : part.to_s }.join("\n")
177
+ when Hash
178
+ content[:text] || content.to_s
179
+ else
180
+ if content.respond_to?(:content)
181
+ content.content
182
+ elsif content.respond_to?(:parts) && content.parts&.first.respond_to?(:text)
183
+ content.parts&.first&.text
184
+ else
185
+ content.to_s
186
+ end
187
+ end
188
+ end
189
+
190
+ # Provider-specific token extraction with fallbacks
191
+ def extract_input_tokens(response_data, provider)
192
+ # Only extract tokens from parsed JSON, not from raw response objects
193
+ return 0 unless response_data[:parsed_json]
194
+
195
+ response_body = response_data[:parsed_json]
196
+
197
+ case provider.to_s
198
+ when 'anthropic'
199
+ response_body.dig("usage", "input_tokens") || 0
200
+ when 'openai'
201
+ response_body.dig("usage", "prompt_tokens") || 0
202
+ when 'cohere'
203
+ response_body.dig("meta", "tokens", "input_tokens") ||
204
+ response_body.dig(:meta, :tokens, :input_tokens) ||
205
+ response_body.dig("meta", "billed_units", "input_tokens") ||
206
+ response_body.dig(:meta, :billed_units, :input_tokens) ||
207
+ response_body.dig("token_count", "prompt_tokens") || 0
208
+ else
209
+ # Try common locations
210
+ response_body.dig("usage", "prompt_tokens") ||
211
+ response_body.dig("usage", "input_tokens") ||
212
+ response_body.dig("meta", "tokens", "input_tokens") ||
213
+ response_body.dig("token_count", "prompt_tokens") ||
214
+ 0
215
+ end
216
+ end
217
+
218
+ def extract_output_tokens(response_data, provider)
219
+ # Only extract tokens from parsed JSON, not from raw response objects
220
+ return 0 unless response_data[:parsed_json]
221
+
222
+ response_body = response_data[:parsed_json]
223
+
224
+ case provider.to_s
225
+ when 'anthropic'
226
+ response_body.dig("usage", "output_tokens") || 0
227
+ when 'openai'
228
+ response_body.dig("usage", "completion_tokens") || 0
229
+ when 'cohere'
230
+ response_body.dig("meta", "tokens", "output_tokens") ||
231
+ response_body.dig(:meta, :tokens, :output_tokens) ||
232
+ response_body.dig("meta", "billed_units", "output_tokens") ||
233
+ response_body.dig(:meta, :billed_units, :output_tokens) ||
234
+ response_body.dig("token_count", "completion_tokens") || 0
235
+ else
236
+ # Try common locations
237
+ response_body.dig("usage", "completion_tokens") ||
238
+ response_body.dig("usage", "output_tokens") ||
239
+ response_body.dig("meta", "tokens", "output_tokens") ||
240
+ response_body.dig("token_count", "completion_tokens") ||
241
+ 0
242
+ end
243
+ end
244
+
245
+ # Provider-specific output extraction with fallbacks
246
+ def extract_output_choices(response_data, provider)
247
+ # Only extract output choices from parsed JSON, not from raw response objects
248
+ return [] unless response_data[:parsed_json]
249
+
250
+ response_body = response_data[:parsed_json]
251
+
252
+ case provider.to_s
253
+ when 'anthropic'
254
+ extract_anthropic_output_choices(response_body)
255
+ when 'openai'
256
+ extract_openai_output_choices(response_body)
257
+ else
258
+ extract_generic_output_choices(response_body)
259
+ end
260
+ end
261
+
262
+ def extract_anthropic_output_choices(response_body)
263
+ # Handle both original Anthropic format and transformed format
264
+ if response_body["content"].is_a?(Array)
265
+ # Original format from Anthropic API
266
+ content_text = response_body["content"].map { |c| c["text"] }.join("\n")
267
+ [{ role: "assistant", content: content_text }]
268
+ elsif response_body["completion"]
269
+ # Transformed format after Anthropic engine processing
270
+ [{ role: "assistant", content: response_body["completion"] }]
271
+ else
272
+ []
273
+ end
274
+ end
275
+
276
+ def extract_openai_output_choices(response_body)
277
+ if response_body["choices"]
278
+ response_body["choices"].map do |choice|
279
+ if choice.dig("message", "content")
280
+ { role: "assistant", content: choice.dig("message", "content") }
281
+ elsif choice["text"]
282
+ { role: "assistant", content: choice["text"] }
283
+ else
284
+ choice
285
+ end
286
+ end
287
+ else
288
+ []
289
+ end
290
+ end
291
+
292
+ def extract_generic_output_choices(response_body)
293
+ # Handle different response formats
294
+ if response_body["choices"]
295
+ response_body["choices"].map do |choice|
296
+ if choice.dig("message", "content")
297
+ { role: "assistant", content: choice.dig("message", "content") }
298
+ elsif choice["text"]
299
+ { role: "assistant", content: choice["text"] }
300
+ else
301
+ choice
302
+ end
303
+ end
304
+ elsif response_body["text"] || response_body[:text]
305
+ # Handle Cohere format (both string and symbol keys)
306
+ content = response_body["text"] || response_body[:text]
307
+ [{ role: "assistant", content: }]
308
+ elsif response_body["message"]
309
+ [{ role: "assistant", content: response_body["message"] }]
310
+ elsif response_body["candidates"]
311
+ response_body["candidates"].map do |candidate|
312
+ content = candidate.dig("content", "parts", 0, "text") || candidate.to_s
313
+ { role: "assistant", content: }
314
+ end
315
+ else
316
+ []
317
+ end
318
+ end
319
+
320
+ def extract_model_name(current_params, provider)
321
+ current_params&.dig(:model) ||
322
+ current_params&.dig("model") ||
323
+ current_params&.dig(:model_name) ||
324
+ current_params&.dig("model_name") ||
325
+ get_default_model_for_provider(provider)
326
+ end
327
+
328
+ def get_default_model_for_provider(provider)
329
+ case provider.to_s
330
+ when 'openai'
331
+ 'gpt-4o-mini'
332
+ when 'anthropic'
333
+ 'claude-3-5-sonnet-20240620'
334
+ when 'cerebras'
335
+ 'llama-3.3-70b'
336
+ when 'cohere'
337
+ 'command-r-plus'
338
+ when 'perplexity_ai'
339
+ 'llama-3-sonar-large-32k-online'
340
+ when 'ollama'
341
+ 'llama3'
342
+ else
343
+ provider.to_s
344
+ end
345
+ end
346
+
347
+ def get_base_url_for_provider(provider)
348
+ case provider.to_s
349
+ when 'cohere'
350
+ 'https://api.cohere.ai/v1'
351
+ when 'anthropic'
352
+ 'https://api.anthropic.com/v1'
353
+ when 'google', 'gemini'
354
+ 'https://generativelanguage.googleapis.com/v1'
355
+ when 'groq'
356
+ 'https://api.groq.com/openai/v1'
357
+ when 'cerebras'
358
+ 'https://api.cerebras.ai/v1'
359
+ when 'openai'
360
+ 'https://api.openai.com/v1'
361
+ when 'perplexity_ai'
362
+ 'https://api.perplexity.ai'
363
+ when 'ollama'
364
+ 'http://localhost:11434/v1'
365
+ else
366
+ "https://api.#{provider}.com/v1"
367
+ end
368
+ end
369
+
370
+ def extract_error_message(response_data, provider)
371
+ if response_data[:error]
372
+ response_data[:error].message
373
+ elsif response_data[:response_obj]
374
+ case provider.to_s
375
+ when 'openai'
376
+ extract_openai_error_message(response_data[:response_obj])
377
+ when 'anthropic'
378
+ extract_anthropic_error_message(response_data[:response_obj])
379
+ else
380
+ # For failed responses, try to get error from reason_phrase or fallback
381
+ if response_data[:response_obj].respond_to?(:reason_phrase)
382
+ response_data[:response_obj].reason_phrase || "API call failed"
383
+ else
384
+ "API call failed"
385
+ end
386
+ end
387
+ else
388
+ "Unknown error"
389
+ end
390
+ end
391
+
392
+ def extract_openai_error_message(response_obj)
393
+ if response_obj.respond_to?(:body) && response_obj.body
394
+ begin
395
+ parsed_body = JSON.parse(response_obj.body)
396
+ if parsed_body["error"]
397
+ err = parsed_body["error"]
398
+ "#{err['type']}: #{err['message']}"
399
+ else
400
+ response_obj.respond_to?(:reason_phrase) ? response_obj.reason_phrase : "Unknown OpenAI error"
401
+ end
402
+ rescue JSON::ParserError
403
+ response_obj.respond_to?(:reason_phrase) ? response_obj.reason_phrase : "Unknown OpenAI error"
404
+ end
405
+ else
406
+ response_obj.respond_to?(:reason_phrase) ? response_obj.reason_phrase : "Unknown OpenAI error"
407
+ end
408
+ end
409
+
410
+ def extract_anthropic_error_message(response_obj)
411
+ if response_obj.respond_to?(:body) && response_obj.body
412
+ begin
413
+ parsed_body = JSON.parse(response_obj.body)
414
+ parsed_body.dig("error", "message") ||
415
+ (response_obj.respond_to?(:reason_phrase) ? response_obj.reason_phrase : "Unknown Anthropic error")
416
+ rescue JSON::ParserError
417
+ response_obj.respond_to?(:reason_phrase) ? response_obj.reason_phrase : "Unknown Anthropic error"
418
+ end
419
+ else
420
+ response_obj.respond_to?(:reason_phrase) ? response_obj.reason_phrase : "Unknown Anthropic error"
421
+ end
422
+ end
423
+
424
+ def extract_status_code(response_data)
425
+ response_data[:status_code] ||
426
+ (response_data[:response_obj].respond_to?(:status) ? response_data[:response_obj].status : nil)
427
+ end
428
+ end
429
+ # rubocop:enable Metrics/ModuleLength
430
+ end
@@ -58,7 +58,7 @@ module Boxcars
58
58
  inkeys = %w[completion_tokens prompt_tokens total_tokens].freeze
59
59
  prompts.each_slice(batch_size) do |sub_prompts|
60
60
  sub_prompts.each do |sprompts, inputs|
61
- response = client(prompt: sprompts, inputs: inputs, **params)
61
+ response = client(prompt: sprompts, inputs:, **params)
62
62
  check_response(response)
63
63
  choices.concat(response["choices"])
64
64
  usage_keys = inkeys & response["usage"].keys
@@ -72,12 +72,14 @@ module Boxcars
72
72
  sub_choices = choices[i * n, (i + 1) * n]
73
73
  generations.push(generation_info(sub_choices))
74
74
  end
75
- EngineResult.new(generations: generations, engine_output: { token_usage: token_usage })
75
+ EngineResult.new(generations:, engine_output: { token_usage: })
76
76
  end
77
77
  end
78
78
  end
79
79
 
80
+ require 'boxcars/engine/unified_observability'
80
81
  require "boxcars/engine/engine_result"
82
+ require "boxcars/engine/intelligence_base"
81
83
  require "boxcars/engine/anthropic"
82
84
  require "boxcars/engine/cohere"
83
85
  require "boxcars/engine/groq"
@@ -86,7 +88,6 @@ require "boxcars/engine/openai"
86
88
  require "boxcars/engine/perplexityai"
87
89
  require "boxcars/engine/gpt4all_eng"
88
90
  require "boxcars/engine/gemini_ai"
89
- require "boxcars/engine/intelligence_base"
90
91
  require "boxcars/engine/cerebras"
91
92
  require "boxcars/engine/google"
92
93
  require "boxcars/engine/together"
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # Factory class for creating engine instances based on model names
5
+ # Provides convenient shortcuts and aliases for different AI models
6
+ class Engines
7
+ DEFAULT_MODEL = "gemini-2.5-flash-preview-05-20"
8
+
9
+ # Create an engine instance based on the model name
10
+ # @param model [String] The model name or alias
11
+ # @param kw_args [Hash] Additional arguments to pass to the engine
12
+ # @return [Boxcars::Engine] An instance of the appropriate engine class
13
+ def self.engine(model: nil, **kw_args)
14
+ model ||= Boxcars.configuration.default_model || DEFAULT_MODEL
15
+ Boxcars.logger&.info { "running api with #{model}" }
16
+
17
+ case model.to_s
18
+ when /^(gpt|o\d)-/
19
+ Boxcars::Openai.new(model:, **kw_args)
20
+ when "anthropic", "sonnet"
21
+ Boxcars::Anthropic.new(model: "claude-sonnet-4-0", **kw_args)
22
+ when "opus", "claude-opus-4-0"
23
+ Boxcars::Anthropic.new(model: "claude-opus-4-0", **kw_args)
24
+ when /claude-/
25
+ Boxcars::Anthropic.new(model:, **kw_args)
26
+ when "groq", "llama-3.3-70b-versatile"
27
+ Boxcars::Groq.new(model: "llama-3.3-70b-versatile", **kw_args)
28
+ when "deepseek"
29
+ Boxcars::Groq.new(model: "deepseek-r1-distill-llama-70b", **kw_args)
30
+ when "mistral"
31
+ Boxcars::Groq.new(model: "mistral-saba-24b", **kw_args)
32
+ when /^mistral-/, %r{^meta-llama/}, /^deepseek-/
33
+ Boxcars::Groq.new(model:, **kw_args)
34
+ when "online", "sonar"
35
+ Boxcars::Perplexityai.new(model: "sonar", **kw_args)
36
+ when "huge", "online_huge", "sonar-huge", "sonar-pro", "sonar_pro"
37
+ Boxcars::Perplexityai.new(model: "sonar-pro", **kw_args)
38
+ when "flash", "gemini-flash"
39
+ Boxcars::GeminiAi.new(model: "gemini-2.5-flash-preview-05-20", **kw_args)
40
+ when "gemini-pro"
41
+ Boxcars::GeminiAi.new(model: "gemini-2.5-pro-preview-05-06", **kw_args)
42
+ when /gemini-/
43
+ Boxcars::GeminiAi.new(model:, **kw_args)
44
+ when /-sonar-/
45
+ Boxcars::Perplexityai.new(model:, **kw_args)
46
+ when /^together-/
47
+ Boxcars::Together.new(model: model[9..-1], **kw_args)
48
+ when "cerebras"
49
+ Boxcars::Cerebras.new(model: "llama-3.3-70b", **kw_args)
50
+ when "qwen"
51
+ Boxcars::Together.new(model: "Qwen/Qwen2.5-VL-72B-Instruct", **kw_args)
52
+ else
53
+ raise Boxcars::ArgumentError, "Unknown model: #{model}"
54
+ end
55
+ end
56
+
57
+ # Create an engine instance optimized for JSON responses
58
+ # @param model [String] The model name or alias
59
+ # @param kw_args [Hash] Additional arguments to pass to the engine
60
+ # @return [Boxcars::Engine] An instance of the appropriate engine class
61
+ def self.json_engine(model: nil, **kw_args)
62
+ options = { temperature: 0.1, response_format: { type: "json_object" } }.merge(kw_args)
63
+ options.delete(:response_format) if model.to_s =~ /sonnet|opus/ || model.to_s.start_with?("llama")
64
+ engine(model:, **options)
65
+ end
66
+
67
+ # Validate that an answer has the expected structure
68
+ # @param answer [Hash] The answer to validate
69
+ # @return [Boolean] True if the answer is valid
70
+ def self.valid_answer?(answer)
71
+ answer.is_a?(Hash) && answer.key?(:answer) && answer[:answer].is_a?(Boxcars::Result)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,44 @@
1
+ module Boxcars
2
+ # Provides a central point for tracking observability events.
3
+ # It allows configuring a backend (or multiple backends via MultiBackend)
4
+ # to which events and their properties will be sent.
5
+ class Observability
6
+ class << self
7
+ # @!attribute [r] backend
8
+ # @return [ObservabilityBackend, nil] The configured observability backend.
9
+ # This should be an object that includes the {Boxcars::ObservabilityBackend} module.
10
+ # It can be a single backend instance or an instance of {Boxcars::MultiBackend}.
11
+ # If `nil`, tracking calls will be no-ops.
12
+ # The backend is retrieved from Boxcars.configuration.observability_backend.
13
+ def backend
14
+ Boxcars.configuration.observability_backend
15
+ end
16
+
17
+ # Tracks an event if a backend is configured.
18
+ # This method will silently ignore errors raised by the backend's `track` method
19
+ # to prevent observability issues from disrupting the main application flow.
20
+ #
21
+ # @param event [String, Symbol] The name of the event to track.
22
+ # @param properties [Hash] A hash of properties associated with the event.
23
+ def track(event:, properties:)
24
+ return unless backend
25
+
26
+ backend.track(event:, properties:)
27
+ rescue StandardError
28
+ # Fail silently as requested.
29
+ # Optionally, if Boxcars had a central logger:
30
+ # Boxcars.logger.warn "Boxcars::Observability: Backend error during track: #{e.message} (#{e.class.name})"
31
+ end
32
+
33
+ # Flushes any pending events if the backend supports it.
34
+ # This is useful for testing or when you need to ensure events are sent before the process exits.
35
+ def flush
36
+ return unless backend
37
+
38
+ backend.flush if backend.respond_to?(:flush)
39
+ rescue StandardError
40
+ # Fail silently as requested.
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
1
+ module Boxcars
2
+ # Module to be included by observability backend implementations.
3
+ # It defines the interface that all backends must adhere to.
4
+ module ObservabilityBackend
5
+ # Tracks an event with associated properties.
6
+ # This method must be implemented by any class that includes this module.
7
+ #
8
+ # @param event [String, Symbol] The name of the event being tracked (e.g., :llm_call, :train_run).
9
+ # @param properties [Hash] A hash of properties associated with the event.
10
+ # Common properties might include :user_id, :prompt, :response, :model_name,
11
+ # :duration_ms, :success, :error_message, etc.
12
+ # @raise [NotImplementedError] if the including class does not implement this method.
13
+ def track(event:, properties:)
14
+ raise NotImplementedError, "#{self.class.name} must implement the `track` method."
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,42 @@
1
+ # Ensure the base module is available.
2
+ # This might be handled by autoloading in a Rails app,
3
+ # but explicit require is safer for gems.
4
+ require_relative '../observability_backend'
5
+
6
+ module Boxcars
7
+ # An observability backend that delegates tracking calls to multiple other backends.
8
+ # This allows sending observability data to several destinations simultaneously.
9
+ class MultiBackend
10
+ include Boxcars::ObservabilityBackend
11
+
12
+ # Initializes a new MultiBackend.
13
+ #
14
+ # @param backends [Array<ObservabilityBackend>] An array of backend instances.
15
+ # Each instance must include {Boxcars::ObservabilityBackend}.
16
+ def initialize(backends)
17
+ @backends = Array(backends).compact # Ensure it's an array and remove nils
18
+ return if @backends.all? { |b| b.respond_to?(:track) }
19
+
20
+ raise ArgumentError, "All backends must implement the `track` method (i.e., include Boxcars::ObservabilityBackend)."
21
+ end
22
+
23
+ # Tracks an event by calling `track` on each configured backend.
24
+ # It passes a duplicated `properties` hash to each backend to prevent
25
+ # unintended modifications if one backend alters the hash.
26
+ # Errors from individual backends are silently ignored to ensure
27
+ # other backends still receive the event.
28
+ #
29
+ # @param event [String, Symbol] The name of the event to track.
30
+ # @param properties [Hash] A hash of properties associated with the event.
31
+ def track(event:, properties:)
32
+ @backends.each do |backend_instance|
33
+ # Pass a duplicated properties hash to prevent mutation issues across backends
34
+ backend_instance.track(event:, properties: properties.dup)
35
+ rescue StandardError
36
+ # Silently ignore errors from individual backends.
37
+ # Optionally, log:
38
+ # Boxcars.logger.warn "Boxcars::MultiBackend: Error in backend #{backend_instance.class.name}: #{e.message}"
39
+ end
40
+ end
41
+ end
42
+ end