rubyllm-observ 0.6.6 → 0.6.7

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +319 -1
  3. data/app/assets/javascripts/observ/controllers/config_editor_controller.js +178 -0
  4. data/app/assets/javascripts/observ/controllers/index.js +29 -0
  5. data/app/assets/javascripts/observ/controllers/message_form_controller.js +24 -2
  6. data/app/assets/stylesheets/observ/_chat.scss +199 -0
  7. data/app/assets/stylesheets/observ/_config_editor.scss +119 -0
  8. data/app/assets/stylesheets/observ/application.scss +1 -0
  9. data/app/controllers/observ/dataset_items_controller.rb +2 -2
  10. data/app/controllers/observ/dataset_runs_controller.rb +1 -1
  11. data/app/controllers/observ/datasets_controller.rb +2 -2
  12. data/app/controllers/observ/messages_controller.rb +5 -1
  13. data/app/controllers/observ/prompts_controller.rb +11 -3
  14. data/app/controllers/observ/scores_controller.rb +1 -1
  15. data/app/controllers/observ/traces_controller.rb +1 -1
  16. data/app/helpers/observ/application_helper.rb +1 -0
  17. data/app/helpers/observ/markdown_helper.rb +29 -0
  18. data/app/helpers/observ/prompts_helper.rb +48 -0
  19. data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
  20. data/app/models/observ/embedding.rb +45 -0
  21. data/app/models/observ/image_generation.rb +38 -0
  22. data/app/models/observ/moderation.rb +40 -0
  23. data/app/models/observ/null_prompt.rb +49 -2
  24. data/app/models/observ/observation.rb +3 -1
  25. data/app/models/observ/session.rb +33 -0
  26. data/app/models/observ/trace.rb +90 -4
  27. data/app/models/observ/transcription.rb +38 -0
  28. data/app/services/observ/chat_instrumenter.rb +96 -6
  29. data/app/services/observ/concerns/observable_service.rb +108 -3
  30. data/app/services/observ/embedding_instrumenter.rb +193 -0
  31. data/app/services/observ/guardrail_service.rb +9 -0
  32. data/app/services/observ/image_generation_instrumenter.rb +243 -0
  33. data/app/services/observ/moderation_guardrail_service.rb +235 -0
  34. data/app/services/observ/moderation_instrumenter.rb +141 -0
  35. data/app/services/observ/transcription_instrumenter.rb +187 -0
  36. data/app/views/observ/chats/show.html.erb +9 -0
  37. data/app/views/observ/messages/_message.html.erb +1 -1
  38. data/app/views/observ/messages/create.turbo_stream.erb +1 -3
  39. data/app/views/observ/prompts/_config_editor.html.erb +115 -0
  40. data/app/views/observ/prompts/_form.html.erb +2 -13
  41. data/app/views/observ/prompts/_new_form.html.erb +2 -12
  42. data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
  43. data/lib/observ/engine.rb +7 -0
  44. data/lib/observ/version.rb +1 -1
  45. metadata +31 -1
@@ -129,6 +129,149 @@
129
129
  color: inherit;
130
130
  }
131
131
  }
132
+
133
+ // Markdown lists
134
+ ul,
135
+ ol {
136
+ margin: 0.5rem 0;
137
+ padding-left: 1.5rem;
138
+
139
+ li {
140
+ margin-bottom: 0.25rem;
141
+
142
+ &:last-child {
143
+ margin-bottom: 0;
144
+ }
145
+ }
146
+ }
147
+
148
+ ul {
149
+ list-style-type: disc;
150
+
151
+ ul {
152
+ list-style-type: circle;
153
+
154
+ ul {
155
+ list-style-type: square;
156
+ }
157
+ }
158
+ }
159
+
160
+ ol {
161
+ list-style-type: decimal;
162
+ }
163
+
164
+ // Markdown blockquotes
165
+ blockquote {
166
+ margin: 0.5rem 0;
167
+ padding: 0.5rem 1rem;
168
+ border-left: 3px solid $observ-border-strong;
169
+ background-color: $observ-bg-elevated;
170
+ color: $observ-text-secondary;
171
+ font-style: italic;
172
+
173
+ p {
174
+ margin: 0;
175
+ }
176
+ }
177
+
178
+ // Markdown headers
179
+ h1,
180
+ h2,
181
+ h3,
182
+ h4,
183
+ h5,
184
+ h6 {
185
+ margin: 1rem 0 0.5rem 0;
186
+ font-weight: 600;
187
+ line-height: 1.3;
188
+ color: $observ-text-primary;
189
+
190
+ &:first-child {
191
+ margin-top: 0;
192
+ }
193
+ }
194
+
195
+ h1 {
196
+ font-size: 1.5em;
197
+ }
198
+
199
+ h2 {
200
+ font-size: 1.3em;
201
+ }
202
+
203
+ h3 {
204
+ font-size: 1.15em;
205
+ }
206
+
207
+ h4,
208
+ h5,
209
+ h6 {
210
+ font-size: 1em;
211
+ }
212
+
213
+ // Markdown tables
214
+ table {
215
+ width: 100%;
216
+ border-collapse: collapse;
217
+ margin: 0.5rem 0;
218
+ font-size: 0.9em;
219
+
220
+ th,
221
+ td {
222
+ padding: 0.5rem;
223
+ border: 1px solid $observ-border-color;
224
+ text-align: left;
225
+ }
226
+
227
+ th {
228
+ background-color: $observ-bg-elevated;
229
+ font-weight: 600;
230
+ }
231
+
232
+ tr:nth-child(even) {
233
+ background-color: rgba($observ-bg-elevated, 0.5);
234
+ }
235
+ }
236
+
237
+ // Markdown horizontal rule
238
+ hr {
239
+ margin: 1rem 0;
240
+ border: none;
241
+ border-top: 1px solid $observ-border-color;
242
+ }
243
+
244
+ // Markdown strikethrough
245
+ del {
246
+ text-decoration: line-through;
247
+ color: $observ-text-muted;
248
+ }
249
+
250
+ // Markdown links
251
+ a {
252
+ color: $observ-primary;
253
+ text-decoration: none;
254
+
255
+ &:hover {
256
+ text-decoration: underline;
257
+ }
258
+ }
259
+
260
+ // Markdown highlight (==text==)
261
+ mark {
262
+ background-color: rgba($observ-warning, 0.3);
263
+ padding: 0.1rem 0.2rem;
264
+ border-radius: $observ-border-radius-sm;
265
+ }
266
+
267
+ // Strong and emphasis
268
+ strong {
269
+ font-weight: 600;
270
+ }
271
+
272
+ em {
273
+ font-style: italic;
274
+ }
132
275
  }
133
276
 
134
277
  &__tool-calls {
@@ -160,6 +303,62 @@
160
303
  }
161
304
  }
162
305
 
306
+ // ==========================================================================
307
+ // TYPING INDICATOR (AI thinking)
308
+ // ==========================================================================
309
+ .observ-typing-indicator {
310
+ display: flex;
311
+ align-items: center;
312
+ gap: $observ-spacing-sm;
313
+ padding: $observ-spacing-md $observ-spacing-lg;
314
+ background-color: $observ-bg-surface;
315
+ border-left: 3px solid $observ-success;
316
+ border-radius: $observ-border-radius-sm;
317
+
318
+ &__dots {
319
+ display: flex;
320
+ align-items: center;
321
+ gap: 4px;
322
+
323
+ span {
324
+ width: 8px;
325
+ height: 8px;
326
+ background-color: $observ-success;
327
+ border-radius: 50%;
328
+ animation: observ-typing-bounce 1.4s ease-in-out infinite;
329
+
330
+ &:nth-child(1) {
331
+ animation-delay: 0s;
332
+ }
333
+
334
+ &:nth-child(2) {
335
+ animation-delay: 0.2s;
336
+ }
337
+
338
+ &:nth-child(3) {
339
+ animation-delay: 0.4s;
340
+ }
341
+ }
342
+ }
343
+
344
+ &__text {
345
+ font-size: $observ-font-size-sm;
346
+ color: $observ-text-secondary;
347
+ font-style: italic;
348
+ }
349
+ }
350
+
351
+ @keyframes observ-typing-bounce {
352
+ 0%, 60%, 100% {
353
+ transform: translateY(0);
354
+ opacity: 0.4;
355
+ }
356
+ 30% {
357
+ transform: translateY(-6px);
358
+ opacity: 1;
359
+ }
360
+ }
361
+
163
362
  // ==========================================================================
164
363
  // MESSAGE FORM (chat input)
165
364
  // ==========================================================================
@@ -0,0 +1,119 @@
1
+ @import 'variables';
2
+ @import 'namespace';
3
+
4
+ @include observ-scoped {
5
+ .observ-config-editor {
6
+ // Main container
7
+ &__fieldset {
8
+ border: 1px solid $observ-border-color;
9
+ border-radius: $observ-border-radius;
10
+ padding: $observ-spacing-lg;
11
+ margin: 0 0 $observ-spacing-lg 0;
12
+ background-color: $observ-bg-surface;
13
+ }
14
+
15
+ &__legend {
16
+ font-size: $observ-font-size-sm;
17
+ font-weight: 600;
18
+ color: $observ-text-secondary;
19
+ text-transform: uppercase;
20
+ letter-spacing: 0.05em;
21
+ padding: 0 $observ-spacing-sm;
22
+ }
23
+
24
+ &__row {
25
+ margin-bottom: $observ-spacing-lg;
26
+
27
+ &:last-child {
28
+ margin-bottom: 0;
29
+ }
30
+ }
31
+
32
+ &__input-group {
33
+ display: flex;
34
+ align-items: center;
35
+ gap: $observ-spacing-sm;
36
+ }
37
+
38
+ &__number-input {
39
+ max-width: 150px;
40
+ }
41
+
42
+ &__range-hint {
43
+ font-size: $observ-font-size-xs;
44
+ color: $observ-text-muted;
45
+ }
46
+
47
+ // Advanced section (collapsible)
48
+ &__advanced {
49
+ border: 1px solid $observ-border-color;
50
+ border-radius: $observ-border-radius;
51
+ background-color: $observ-bg-surface;
52
+
53
+ &[open] {
54
+ .observ-config-editor__advanced-summary {
55
+ border-bottom: 1px solid $observ-border-subtle;
56
+ }
57
+ }
58
+ }
59
+
60
+ &__advanced-summary {
61
+ padding: $observ-spacing-md $observ-spacing-lg;
62
+ cursor: pointer;
63
+ font-weight: 500;
64
+ color: $observ-text-secondary;
65
+ transition: $observ-transition;
66
+ list-style: none;
67
+
68
+ &::-webkit-details-marker {
69
+ display: none;
70
+ }
71
+
72
+ &::before {
73
+ content: '▸ ';
74
+ display: inline-block;
75
+ transition: transform 0.2s ease;
76
+ }
77
+
78
+ &:hover {
79
+ color: $observ-text-primary;
80
+ background-color: $observ-bg-hover;
81
+ }
82
+ }
83
+
84
+ &__advanced[open] &__advanced-summary::before {
85
+ transform: rotate(90deg);
86
+ }
87
+
88
+ &__advanced-content {
89
+ padding: $observ-spacing-lg;
90
+ }
91
+
92
+ // Validation status
93
+ &__status {
94
+ margin-top: $observ-spacing-sm;
95
+ padding: $observ-spacing-sm $observ-spacing-md;
96
+ border-radius: $observ-border-radius-sm;
97
+ font-size: $observ-font-size-sm;
98
+ font-family: $observ-font-family-mono;
99
+
100
+ &--success {
101
+ background-color: rgba($observ-success, 0.1);
102
+ color: lighten($observ-success, 15%);
103
+ }
104
+
105
+ &--error {
106
+ background-color: rgba($observ-danger, 0.1);
107
+ color: lighten($observ-danger, 15%);
108
+ }
109
+
110
+ &--valid {
111
+ color: lighten($observ-success, 15%);
112
+ }
113
+
114
+ &--invalid {
115
+ color: lighten($observ-danger, 15%);
116
+ }
117
+ }
118
+ }
119
+ }
@@ -12,6 +12,7 @@
12
12
  @import 'drawer';
13
13
  @import 'annotations';
14
14
  @import 'prompts';
15
+ @import 'config_editor';
15
16
  @import 'datasets';
16
17
  @import 'filters';
17
18
  @import 'json_viewer';
@@ -26,7 +26,7 @@ module Observ
26
26
  redirect_to dataset_path(@dataset, tab: "items"),
27
27
  notice: "Item added to dataset successfully."
28
28
  else
29
- render :new, status: :unprocessable_entity
29
+ render :new, status: :unprocessable_content
30
30
  end
31
31
  end
32
32
 
@@ -38,7 +38,7 @@ module Observ
38
38
  redirect_to dataset_path(@dataset, tab: "items"),
39
39
  notice: "Item updated successfully."
40
40
  else
41
- render :edit, status: :unprocessable_entity
41
+ render :edit, status: :unprocessable_content
42
42
  end
43
43
  end
44
44
 
@@ -40,7 +40,7 @@ module Observ
40
40
  redirect_to dataset_run_path(@dataset, @run),
41
41
  notice: "Run '#{@run.name}' created with #{@run.total_items} items. Execution will begin shortly."
42
42
  else
43
- render :new, status: :unprocessable_entity
43
+ render :new, status: :unprocessable_content
44
44
  end
45
45
  end
46
46
 
@@ -32,7 +32,7 @@ module Observ
32
32
  redirect_to dataset_path(@dataset), notice: "Dataset '#{@dataset.name}' created successfully."
33
33
  else
34
34
  @agents = available_agents
35
- render :new, status: :unprocessable_entity
35
+ render :new, status: :unprocessable_content
36
36
  end
37
37
  end
38
38
 
@@ -45,7 +45,7 @@ module Observ
45
45
  redirect_to dataset_path(@dataset), notice: "Dataset '#{@dataset.name}' updated successfully."
46
46
  else
47
47
  @agents = available_agents
48
- render :edit, status: :unprocessable_entity
48
+ render :edit, status: :unprocessable_content
49
49
  end
50
50
  end
51
51
 
@@ -5,7 +5,11 @@ module Observ
5
5
  def create
6
6
  return unless content.present?
7
7
 
8
- ChatResponseJob.perform_later(@chat.id, content)
8
+ # Create user message synchronously so it appears immediately
9
+ @message = @chat.messages.create!(role: :user, content: content)
10
+
11
+ # Enqueue job to get assistant response (will broadcast when complete)
12
+ ChatResponseJob.perform_later(@chat.id, @message.id)
9
13
 
10
14
  respond_to do |format|
11
15
  format.turbo_stream
@@ -103,7 +103,13 @@ module Observ
103
103
  # Parse config JSON string before updating
104
104
  update_params = prompt_params.except(:name, :version, :promote_to_production)
105
105
  if update_params[:config].present?
106
- update_params[:config] = parse_config(update_params[:config])
106
+ parsed = parse_config(update_params[:config])
107
+ if parsed.nil?
108
+ # JSON parsing failed, re-render form with error
109
+ render :edit, status: :unprocessable_content
110
+ return
111
+ end
112
+ update_params[:config] = parsed
107
113
  end
108
114
 
109
115
  if @prompt.update(update_params)
@@ -180,8 +186,10 @@ module Observ
180
186
  def parse_config(config_string)
181
187
  return {} if config_string.blank?
182
188
  JSON.parse(config_string)
183
- rescue JSON::ParserError
184
- {}
189
+ rescue JSON::ParserError => e
190
+ # Add error to flash for display
191
+ flash.now[:alert] = "Invalid JSON configuration: #{e.message}"
192
+ nil
185
193
  end
186
194
 
187
195
  def current_user_identifier
@@ -26,7 +26,7 @@ module Observ
26
26
  end
27
27
  else
28
28
  respond_to do |format|
29
- format.turbo_stream { render :create_error, status: :unprocessable_entity }
29
+ format.turbo_stream { render :create_error, status: :unprocessable_content }
30
30
  format.html { redirect_back(fallback_location: root_path, alert: "Failed to save score.") }
31
31
  end
32
32
  end
@@ -60,7 +60,7 @@ module Observ
60
60
  else
61
61
  @datasets = Observ::Dataset.order(:name)
62
62
  flash.now[:alert] = "Failed to add trace to dataset: #{@item.errors.full_messages.join(', ')}"
63
- render :add_to_dataset_drawer, status: :unprocessable_entity
63
+ render :add_to_dataset_drawer, status: :unprocessable_content
64
64
  end
65
65
  end
66
66
 
@@ -6,5 +6,6 @@ module Observ
6
6
  include Observ::PaginationHelper
7
7
  include Observ::DatasetsHelper
8
8
  include Observ::ReviewsHelper
9
+ include Observ::MarkdownHelper
9
10
  end
10
11
  end
@@ -0,0 +1,29 @@
1
+ require "redcarpet"
2
+
3
+ module Observ
4
+ module MarkdownHelper
5
+ def render_markdown(content)
6
+ return "" if content.blank?
7
+
8
+ markdown_renderer.render(content).html_safe
9
+ end
10
+
11
+ private
12
+
13
+ def markdown_renderer
14
+ @markdown_renderer ||= Redcarpet::Markdown.new(
15
+ Redcarpet::Render::HTML.new(
16
+ hard_wrap: true,
17
+ link_attributes: { target: "_blank", rel: "noopener noreferrer" }
18
+ ),
19
+ autolink: true,
20
+ fenced_code_blocks: true,
21
+ tables: true,
22
+ strikethrough: true,
23
+ no_intra_emphasis: true,
24
+ highlight: true,
25
+ quote: true
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ module PromptsHelper
5
+ # Returns model options grouped by provider for use with grouped_collection_select
6
+ # or manually building optgroups
7
+ def chat_model_options_grouped
8
+ return [] unless defined?(RubyLLM) && RubyLLM.respond_to?(:models)
9
+
10
+ RubyLLM.models.chat_models
11
+ .group_by(&:provider)
12
+ .sort_by { |provider, _| provider }
13
+ .map do |provider, models|
14
+ [
15
+ provider.titleize,
16
+ models.sort_by(&:display_name).map { |m| [ m.display_name, m.id ] }
17
+ ]
18
+ end
19
+ rescue StandardError => e
20
+ Rails.logger.warn "[Observ] Failed to load RubyLLM models: #{e.message}"
21
+ []
22
+ end
23
+
24
+ # Extract config value with fallback
25
+ def config_value(prompt, key, default = nil)
26
+ config = prompt_config_hash(prompt)
27
+ return default unless config.is_a?(Hash)
28
+
29
+ config[key.to_s] || config[key.to_sym] || default
30
+ end
31
+
32
+ private
33
+
34
+ # Extract config hash from prompt or form object
35
+ def prompt_config_hash(prompt)
36
+ return {} unless prompt
37
+
38
+ # Handle PromptForm which has config as string
39
+ if prompt.respond_to?(:parsed_config)
40
+ prompt.parsed_config
41
+ elsif prompt.respond_to?(:config)
42
+ prompt.config
43
+ else
44
+ {}
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class ModerationGuardrailJob < ApplicationJob
5
+ queue_as :moderation
6
+
7
+ # Retry configuration
8
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
9
+ discard_on ActiveRecord::RecordNotFound
10
+
11
+ # Process a single trace or session
12
+ #
13
+ # @param trace_id [Integer] ID of the trace to moderate
14
+ # @param session_id [Integer] ID of the session to moderate
15
+ # @param options [Hash] Options for moderation
16
+ # @option options [Boolean] :moderate_input Whether to moderate input (default: true)
17
+ # @option options [Boolean] :moderate_output Whether to moderate output (default: true)
18
+ # @option options [Boolean] :aggregate Whether to moderate aggregated session content
19
+ def perform(trace_id: nil, session_id: nil, **options)
20
+ if trace_id
21
+ moderate_trace(trace_id, options)
22
+ elsif session_id
23
+ moderate_session(session_id, options)
24
+ else
25
+ Rails.logger.warn "[ModerationGuardrailJob] No trace_id or session_id provided"
26
+ end
27
+ end
28
+
29
+ # Class method to enqueue moderation for traces matching criteria
30
+ #
31
+ # @param scope [ActiveRecord::Relation] Scope of traces to moderate
32
+ # @param sample_percentage [Integer] Percentage of traces to sample (1-100)
33
+ def self.enqueue_for_scope(scope, sample_percentage: 100)
34
+ traces = scope.left_joins(:review_item)
35
+ .where(observ_review_items: { id: nil })
36
+
37
+ if sample_percentage < 100
38
+ sample_size = (traces.count * sample_percentage / 100.0).ceil
39
+ traces = traces.order("RANDOM()").limit(sample_size)
40
+ end
41
+
42
+ traces.find_each do |trace|
43
+ perform_later(trace_id: trace.id)
44
+ end
45
+ end
46
+
47
+ # Enqueue moderation for user-facing sessions only
48
+ #
49
+ # @param since [Time] Only process sessions created after this time
50
+ def self.enqueue_user_facing(since: 1.hour.ago)
51
+ Observ::Session
52
+ .where(created_at: since..)
53
+ .where("metadata->>'user_facing' = ?", "true")
54
+ .find_each do |session|
55
+ perform_later(session_id: session.id)
56
+ end
57
+ end
58
+
59
+ # Enqueue moderation for specific agent types
60
+ #
61
+ # @param agent_types [Array<String>] Agent types to moderate
62
+ # @param since [Time] Only process sessions created after this time
63
+ def self.enqueue_for_agent_types(agent_types, since: 1.hour.ago)
64
+ Observ::Session
65
+ .where(created_at: since..)
66
+ .where("metadata->>'agent_type' IN (?)", agent_types)
67
+ .find_each do |session|
68
+ perform_later(session_id: session.id)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def moderate_trace(trace_id, options)
75
+ trace = Observ::Trace.find(trace_id)
76
+
77
+ service = ModerationGuardrailService.new
78
+ result = service.evaluate_trace(
79
+ trace,
80
+ moderate_input: options.fetch(:moderate_input, true),
81
+ moderate_output: options.fetch(:moderate_output, true)
82
+ )
83
+
84
+ log_result("Trace #{trace_id}", result)
85
+ end
86
+
87
+ def moderate_session(session_id, options)
88
+ session = Observ::Session.find(session_id)
89
+
90
+ service = ModerationGuardrailService.new
91
+
92
+ if options[:aggregate]
93
+ # Moderate aggregated session content
94
+ result = service.evaluate_session_content(session)
95
+ log_result("Session #{session_id} (aggregated)", result)
96
+ else
97
+ # Moderate each trace individually
98
+ results = service.evaluate_session(session)
99
+ flagged_count = results.count(&:flagged?)
100
+ Rails.logger.info "[ModerationGuardrailJob] Session #{session_id}: #{flagged_count}/#{results.size} traces flagged"
101
+ end
102
+ end
103
+
104
+ def log_result(identifier, result)
105
+ case result.action
106
+ when :flagged
107
+ Rails.logger.info "[ModerationGuardrailJob] #{identifier} flagged (#{result.priority}): #{result.details[:flagged_categories]}"
108
+ when :skipped
109
+ Rails.logger.debug "[ModerationGuardrailJob] #{identifier} skipped: #{result.reason}"
110
+ when :passed
111
+ Rails.logger.debug "[ModerationGuardrailJob] #{identifier} passed moderation"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class Embedding < Observation
5
+ # Set input texts for the embedding call
6
+ def set_input(texts)
7
+ update!(
8
+ input: texts.is_a?(Array) ? texts.to_json : texts
9
+ )
10
+ end
11
+
12
+ def finalize(output:, usage: {}, cost_usd: 0.0, status_message: nil)
13
+ merged_usage = (self.usage || {}).merge(usage.stringify_keys)
14
+
15
+ update!(
16
+ output: output.is_a?(String) ? output : output.to_json,
17
+ usage: merged_usage,
18
+ cost_usd: cost_usd,
19
+ end_time: Time.current,
20
+ status_message: status_message
21
+ )
22
+ end
23
+
24
+ # Embedding-specific helpers
25
+ def input_tokens
26
+ usage&.dig("input_tokens") || 0
27
+ end
28
+
29
+ def total_tokens
30
+ input_tokens # Embeddings only have input tokens
31
+ end
32
+
33
+ def batch_size
34
+ metadata&.dig("batch_size") || 1
35
+ end
36
+
37
+ def dimensions
38
+ metadata&.dig("dimensions")
39
+ end
40
+
41
+ def vectors_count
42
+ metadata&.dig("vectors_count") || 1
43
+ end
44
+ end
45
+ end