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.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -3
- data/.ruby-version +1 -1
- data/CHANGELOG.md +41 -0
- data/Gemfile +3 -13
- data/Gemfile.lock +29 -25
- data/POSTHOG_TEST_README.md +118 -0
- data/README.md +305 -0
- data/boxcars.gemspec +1 -2
- data/lib/boxcars/boxcar/active_record.rb +9 -10
- data/lib/boxcars/boxcar/calculator.rb +2 -2
- data/lib/boxcars/boxcar/engine_boxcar.rb +4 -4
- data/lib/boxcars/boxcar/google_search.rb +2 -2
- data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
- data/lib/boxcars/boxcar/ruby_calculator.rb +1 -1
- data/lib/boxcars/boxcar/sql_base.rb +4 -4
- data/lib/boxcars/boxcar/swagger.rb +3 -3
- data/lib/boxcars/boxcar/vector_answer.rb +3 -3
- data/lib/boxcars/boxcar/xml_engine_boxcar.rb +1 -1
- data/lib/boxcars/boxcar.rb +6 -6
- data/lib/boxcars/conversation_prompt.rb +3 -3
- data/lib/boxcars/engine/anthropic.rb +121 -23
- data/lib/boxcars/engine/cerebras.rb +2 -2
- data/lib/boxcars/engine/cohere.rb +135 -9
- data/lib/boxcars/engine/gemini_ai.rb +151 -76
- data/lib/boxcars/engine/google.rb +2 -2
- data/lib/boxcars/engine/gpt4all_eng.rb +92 -34
- data/lib/boxcars/engine/groq.rb +124 -73
- data/lib/boxcars/engine/intelligence_base.rb +52 -17
- data/lib/boxcars/engine/ollama.rb +127 -47
- data/lib/boxcars/engine/openai.rb +186 -103
- data/lib/boxcars/engine/perplexityai.rb +116 -136
- data/lib/boxcars/engine/together.rb +2 -2
- data/lib/boxcars/engine/unified_observability.rb +430 -0
- data/lib/boxcars/engine.rb +4 -3
- data/lib/boxcars/engines.rb +74 -0
- data/lib/boxcars/observability.rb +44 -0
- data/lib/boxcars/observability_backend.rb +17 -0
- data/lib/boxcars/observability_backends/multi_backend.rb +42 -0
- data/lib/boxcars/observability_backends/posthog_backend.rb +89 -0
- data/lib/boxcars/observation.rb +8 -8
- data/lib/boxcars/prompt.rb +16 -4
- data/lib/boxcars/result.rb +7 -12
- data/lib/boxcars/ruby_repl.rb +1 -1
- data/lib/boxcars/train/train_action.rb +1 -1
- data/lib/boxcars/train/xml_train.rb +3 -3
- data/lib/boxcars/train/xml_zero_shot.rb +1 -1
- data/lib/boxcars/train/zero_shot.rb +3 -3
- data/lib/boxcars/train.rb +1 -1
- data/lib/boxcars/vector_search.rb +5 -5
- data/lib/boxcars/vector_store/pgvector/build_from_array.rb +116 -88
- data/lib/boxcars/vector_store/pgvector/build_from_files.rb +106 -80
- data/lib/boxcars/vector_store/pgvector/save_to_database.rb +148 -122
- data/lib/boxcars/vector_store/pgvector/search.rb +157 -131
- data/lib/boxcars/vector_store.rb +4 -4
- data/lib/boxcars/version.rb +1 -1
- data/lib/boxcars.rb +31 -20
- 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
|
data/lib/boxcars/engine.rb
CHANGED
@@ -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
|
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
|
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
|