dspy 0.24.0 → 0.24.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8fb5750b0bc898e3bfb2b64305e5b7eb186c494aba525fd8b739822385e7129
4
- data.tar.gz: 530f9d8163c914845a2b472cdacce848ba187b9e69f9304d8743966b744ddf9f
3
+ metadata.gz: '0946116ac08ee09e62d204db418f2d45f62eb4d4b1eff8306de1780a8cdfba8f'
4
+ data.tar.gz: a294c49d86d084940738ebb39da6c7d3b8fef15064ff2c2cb15c07a90acdec8f
5
5
  SHA512:
6
- metadata.gz: 9f8e5fa70b1be656f174e5370f529086cb6c836030382c04fd1392e79998c43e93b71db7dbc47ec3cf424254cbd42343ac6f1447f800185d6ce499204dec05f2
7
- data.tar.gz: 95971087479ca0efa66da1bb6084e94a6cb2120203721c2a949c6fc002d152da9f4587061e8e67c246760dbee5167718f8a6f067024e61106d9fa94c1bd0cd55
6
+ metadata.gz: 91141a65593592604c301f3c51f48fac2c29b654c871c3155a055202d25efdcf7511ff4fda0372021f686b864f1e078e89fed27d245300f20966de62e3e295c5
7
+ data.tar.gz: e4885370fde056ff4dda5464901f0f3e460bbf0cf2985e079088adf67d091718fe72e8ee0efc34999b021bb3e238649a782f12c3fec1162a8b6b13576bd25dc0
data/README.md CHANGED
@@ -191,6 +191,7 @@ DSPy.rb has rapidly evolved from experimental to production-ready:
191
191
  - ✅ **Optimization Framework** - MIPROv2 algorithm with storage & persistence
192
192
 
193
193
  ### Recent Advances
194
+ - ✅ **Enhanced Langfuse Integration (v0.24.1)** - Comprehensive OpenTelemetry span reporting with proper input/output, hierarchical nesting, accurate timing, and observation types
194
195
  - ✅ **Comprehensive Multimodal Framework** - Complete image analysis with `DSPy::Image`, type-safe bounding boxes, vision model integration
195
196
  - ✅ **Advanced Type System** - `T::Enum` integration, union types for agentic workflows, complex type coercion
196
197
  - ✅ **Production-Ready Evaluation** - Multi-factor metrics beyond accuracy, error-resilient evaluation pipelines
@@ -82,16 +82,45 @@ module DSPy
82
82
  sig { returns(T.class_of(DSPy::Signature)) }
83
83
  attr_reader :original_signature
84
84
 
85
- # Override forward_untyped to add ChainOfThought-specific analysis
85
+ # Override forward_untyped to add ChainOfThought-specific analysis and tracing
86
86
  sig { override.params(input_values: T.untyped).returns(T.untyped) }
87
87
  def forward_untyped(**input_values)
88
- # Call parent prediction logic
89
- prediction_result = super(**input_values)
90
-
91
- # Analyze reasoning if present
92
- analyze_reasoning(prediction_result)
93
-
94
- prediction_result
88
+ # Wrap in chain-specific span tracking (overrides parent's span attributes)
89
+ DSPy::Context.with_span(
90
+ operation: "ChainOfThought.forward",
91
+ 'langfuse.observation.type' => 'chain',
92
+ 'langfuse.observation.input' => input_values.to_json,
93
+ 'dspy.module' => 'ChainOfThought',
94
+ 'dspy.signature' => @original_signature.name
95
+ ) do |span|
96
+ # Call parent prediction logic (which will create its own nested span)
97
+ prediction_result = super(**input_values)
98
+
99
+ # Enhance span with reasoning data
100
+ if span && prediction_result
101
+ # Include reasoning in output for chain observation
102
+ output_with_reasoning = if prediction_result.respond_to?(:reasoning) && prediction_result.reasoning
103
+ output_hash = prediction_result.respond_to?(:to_h) ? prediction_result.to_h : {}
104
+ output_hash.merge(reasoning: prediction_result.reasoning)
105
+ else
106
+ prediction_result.respond_to?(:to_h) ? prediction_result.to_h : prediction_result.to_s
107
+ end
108
+
109
+ span.set_attribute('langfuse.observation.output', output_with_reasoning.to_json)
110
+
111
+ # Add reasoning metrics
112
+ if prediction_result.respond_to?(:reasoning) && prediction_result.reasoning
113
+ span.set_attribute('cot.reasoning_length', prediction_result.reasoning.length)
114
+ span.set_attribute('cot.has_reasoning', true)
115
+ span.set_attribute('cot.reasoning_steps', count_reasoning_steps(prediction_result.reasoning))
116
+ end
117
+ end
118
+
119
+ # Analyze reasoning (emits events for backwards compatibility)
120
+ analyze_reasoning(prediction_result)
121
+
122
+ prediction_result
123
+ end
95
124
  end
96
125
 
97
126
  private
data/lib/dspy/context.rb CHANGED
@@ -26,37 +26,45 @@ module DSPy
26
26
  **attributes
27
27
  }
28
28
 
29
- # Log span start with proper hierarchy
29
+ # Log span start with proper hierarchy (internal logging only)
30
30
  DSPy.log('span.start', **span_attributes)
31
31
 
32
- # Create OpenTelemetry span if observability is enabled
33
- otel_span = nil
34
- if DSPy::Observability.enabled?
35
- otel_span = DSPy::Observability.start_span(operation, span_attributes)
36
- end
37
-
38
- # Push to stack for child spans
32
+ # Push to stack for child spans tracking
39
33
  current[:span_stack].push(span_id)
40
34
 
41
35
  begin
42
- result = yield
36
+ # Use OpenTelemetry's proper context management for nesting
37
+ if DSPy::Observability.enabled? && DSPy::Observability.tracer
38
+ # Prepare attributes and add trace name for root spans
39
+ span_attributes = attributes.transform_keys(&:to_s).reject { |k, v| v.nil? }
40
+
41
+ # Set trace name if this is likely a root span (no parent in our stack)
42
+ if current[:span_stack].length == 1 # This will be the first span
43
+ span_attributes['langfuse.trace.name'] = operation
44
+ end
45
+
46
+ DSPy::Observability.tracer.in_span(
47
+ operation,
48
+ attributes: span_attributes,
49
+ kind: :internal
50
+ ) do |span|
51
+ yield(span)
52
+ end
53
+ else
54
+ yield(nil)
55
+ end
43
56
  ensure
44
57
  # Pop from stack
45
58
  current[:span_stack].pop
46
59
 
47
- # Log span end with duration
60
+ # Log span end with duration (internal logging only)
48
61
  duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
49
62
  DSPy.log('span.end',
50
63
  trace_id: current[:trace_id],
51
64
  span_id: span_id,
52
65
  duration_ms: duration_ms
53
66
  )
54
-
55
- # Finish OpenTelemetry span
56
- DSPy::Observability.finish_span(otel_span) if otel_span
57
67
  end
58
-
59
- result
60
68
  end
61
69
 
62
70
  def clear!
@@ -16,18 +16,26 @@ module DSPy
16
16
 
17
17
  def chat(messages:, signature: nil, response_format: nil, &block)
18
18
  normalized_messages = normalize_messages(messages)
19
-
19
+
20
20
  # Validate vision support if images are present
21
21
  if contains_images?(normalized_messages)
22
22
  VisionModels.validate_vision_support!('openai', model)
23
23
  # Convert messages to OpenAI format with proper image handling
24
24
  normalized_messages = format_multimodal_messages(normalized_messages)
25
25
  end
26
-
26
+
27
+ # Set temperature based on model capabilities
28
+ temperature = case model
29
+ when /^gpt-5/, /^gpt-4o/
30
+ 1.0 # GPT-5 and GPT-4o models only support default temperature of 1.0
31
+ else
32
+ 0.0 # Near-deterministic for other models (0.0 no longer universally supported)
33
+ end
34
+
27
35
  request_params = {
28
36
  model: model,
29
37
  messages: normalized_messages,
30
- temperature: 0.0 # DSPy default for deterministic responses
38
+ temperature: temperature
31
39
  }
32
40
 
33
41
  # Add response format if provided by strategy
@@ -48,7 +56,7 @@ module DSPy
48
56
 
49
57
  begin
50
58
  response = @client.chat.completions.create(**request_params)
51
-
59
+
52
60
  if response.respond_to?(:error) && response.error
53
61
  raise AdapterError, "OpenAI API error: #{response.error}"
54
62
  end
@@ -65,7 +73,7 @@ module DSPy
65
73
 
66
74
  # Convert usage data to typed struct
67
75
  usage_struct = UsageFactory.create('openai', usage)
68
-
76
+
69
77
  # Create typed metadata
70
78
  metadata = ResponseMetadataFactory.create('openai', {
71
79
  model: model,
@@ -75,7 +83,7 @@ module DSPy
75
83
  system_fingerprint: response.system_fingerprint,
76
84
  finish_reason: choice.finish_reason
77
85
  })
78
-
86
+
79
87
  Response.new(
80
88
  content: content,
81
89
  usage: usage_struct,
@@ -84,14 +92,14 @@ module DSPy
84
92
  rescue => e
85
93
  # Check for specific error types and messages
86
94
  error_msg = e.message.to_s
87
-
95
+
88
96
  # Try to parse error body if it looks like JSON
89
97
  error_body = if error_msg.start_with?('{')
90
98
  JSON.parse(error_msg) rescue nil
91
99
  elsif e.respond_to?(:response) && e.response
92
100
  e.response[:body] rescue nil
93
101
  end
94
-
102
+
95
103
  # Check for specific image-related errors
96
104
  if error_msg.include?('image_parse_error') || error_msg.include?('unsupported image')
97
105
  raise AdapterError, "Image processing failed: #{error_msg}. Ensure your image is a valid PNG, JPEG, GIF, or WebP format and under 5MB."
@@ -113,7 +121,7 @@ module DSPy
113
121
  def supports_structured_outputs?
114
122
  DSPy::LM::Adapters::OpenAI::SchemaConverter.supports_structured_outputs?(model)
115
123
  end
116
-
124
+
117
125
  def format_multimodal_messages(messages)
118
126
  messages.map do |msg|
119
127
  if msg[:content].is_a?(Array)
@@ -130,7 +138,7 @@ module DSPy
130
138
  item
131
139
  end
132
140
  end
133
-
141
+
134
142
  {
135
143
  role: msg[:role],
136
144
  content: formatted_content
data/lib/dspy/lm.rb CHANGED
@@ -209,35 +209,48 @@ module DSPy
209
209
 
210
210
  # Common instrumentation method for LM requests
211
211
  def instrument_lm_request(messages, signature_class_name, &execution_block)
212
- # Handle both Message objects and hash format
213
- input_text = messages.map do |m|
212
+ # Prepare input for tracing - convert messages to JSON for input tracking
213
+ input_messages = messages.map do |m|
214
214
  if m.is_a?(Message)
215
- m.content
215
+ { role: m.role, content: m.content }
216
216
  else
217
- m[:content]
217
+ m
218
218
  end
219
- end.join(' ')
220
- input_size = input_text.length
219
+ end
220
+ input_json = input_messages.to_json
221
221
 
222
222
  # Wrap LLM call in span tracking
223
223
  response = DSPy::Context.with_span(
224
224
  operation: 'llm.generate',
225
+ 'langfuse.observation.type' => 'generation',
226
+ 'langfuse.observation.input' => input_json,
225
227
  'gen_ai.system' => provider,
226
228
  'gen_ai.request.model' => model,
229
+ 'gen_ai.prompt' => input_json,
227
230
  'dspy.signature' => signature_class_name
228
- ) do
231
+ ) do |span|
229
232
  result = execution_block.call
230
233
 
231
- # Add usage data if available
232
- if result.respond_to?(:usage) && result.usage
233
- usage = result.usage
234
- DSPy.log('span.attributes',
235
- span_id: DSPy::Context.current[:span_stack].last,
236
- 'gen_ai.response.model' => result.metadata.model,
237
- 'gen_ai.usage.prompt_tokens' => usage.input_tokens,
238
- 'gen_ai.usage.completion_tokens' => usage.output_tokens,
239
- 'gen_ai.usage.total_tokens' => usage.total_tokens
240
- )
234
+ # Add output and usage data directly to span
235
+ if span && result
236
+ # Add completion output
237
+ if result.content
238
+ span.set_attribute('langfuse.observation.output', result.content)
239
+ span.set_attribute('gen_ai.completion', result.content)
240
+ end
241
+
242
+ # Add response model if available
243
+ if result.respond_to?(:metadata) && result.metadata&.model
244
+ span.set_attribute('gen_ai.response.model', result.metadata.model)
245
+ end
246
+
247
+ # Add token usage
248
+ if result.respond_to?(:usage) && result.usage
249
+ usage = result.usage
250
+ span.set_attribute('gen_ai.usage.prompt_tokens', usage.input_tokens) if usage.input_tokens
251
+ span.set_attribute('gen_ai.usage.completion_tokens', usage.output_tokens) if usage.output_tokens
252
+ span.set_attribute('gen_ai.usage.total_tokens', usage.total_tokens) if usage.total_tokens
253
+ end
241
254
  end
242
255
 
243
256
  result
@@ -20,7 +20,7 @@ module DSPy
20
20
 
21
21
  # Determine endpoint based on host
22
22
  host = ENV['LANGFUSE_HOST'] || 'https://cloud.langfuse.com'
23
- @endpoint = "#{host}/api/public/otel"
23
+ @endpoint = "#{host}/api/public/otel/v1/traces"
24
24
 
25
25
  begin
26
26
  # Load OpenTelemetry gems
@@ -73,6 +73,10 @@ module DSPy
73
73
  @enabled == true
74
74
  end
75
75
 
76
+ def tracer
77
+ @tracer
78
+ end
79
+
76
80
  def start_span(operation_name, attributes = {})
77
81
  return nil unless enabled? && tracer
78
82
 
data/lib/dspy/predict.rb CHANGED
@@ -141,9 +141,11 @@ module DSPy
141
141
  # Wrap prediction in span tracking
142
142
  DSPy::Context.with_span(
143
143
  operation: "#{self.class.name}.forward",
144
+ 'langfuse.observation.type' => 'span',
145
+ 'langfuse.observation.input' => input_values.to_json,
144
146
  'dspy.module' => self.class.name,
145
147
  'dspy.signature' => @signature_class.name
146
- ) do
148
+ ) do |span|
147
149
  # Validate input
148
150
  validate_input_struct(input_values)
149
151
 
@@ -158,7 +160,15 @@ module DSPy
158
160
  processed_output = process_lm_output(output_attributes)
159
161
 
160
162
  # Create combined result struct
161
- create_prediction_result(input_values, processed_output)
163
+ prediction_result = create_prediction_result(input_values, processed_output)
164
+
165
+ # Add output to span
166
+ if span && prediction_result
167
+ output_hash = prediction_result.respond_to?(:to_h) ? prediction_result.to_h : prediction_result.to_s
168
+ span.set_attribute('langfuse.observation.output', output_hash.to_json)
169
+ end
170
+
171
+ prediction_result
162
172
  end
163
173
  end
164
174
 
data/lib/dspy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DSPy
4
- VERSION = "0.24.0"
4
+ VERSION = "0.24.1"
5
5
  end
data/lib/dspy.rb CHANGED
@@ -99,8 +99,21 @@ module DSPy
99
99
  logger.info(attributes)
100
100
  end
101
101
 
102
+ # Internal events that should not create OpenTelemetry spans
103
+ INTERNAL_EVENTS = [
104
+ 'span.start',
105
+ 'span.end',
106
+ 'span.attributes',
107
+ 'observability.disabled',
108
+ 'observability.error',
109
+ 'observability.span_error',
110
+ 'observability.span_finish_error',
111
+ 'event.span_creation_error'
112
+ ].freeze
113
+
102
114
  def self.create_event_span(event_name, attributes)
103
115
  return unless DSPy::Observability.enabled?
116
+ return if INTERNAL_EVENTS.include?(event_name)
104
117
 
105
118
  begin
106
119
  # Flatten nested hashes for OpenTelemetry span attributes
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.24.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: 0.16.0
60
+ version: 0.22.0
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: 0.16.0
67
+ version: 0.22.0
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: anthropic
70
70
  requirement: !ruby/object:Gem::Requirement