dspy 0.27.0 → 0.27.2

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.
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "sorbet-runtime"
4
- require_relative "../../cache_manager"
5
4
 
6
5
  module DSPy
7
6
  class LM
@@ -22,22 +21,12 @@ module DSPy
22
21
 
23
22
  sig { params(signature_class: T.class_of(DSPy::Signature), name: T.nilable(String), strict: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
24
23
  def self.to_openai_format(signature_class, name: nil, strict: true)
25
- # Build cache params from the method parameters
26
- cache_params = { strict: strict }
27
- cache_params[:name] = name if name
28
-
29
- # Check cache first
30
- cache_manager = DSPy::LM.cache_manager
31
- cached_schema = cache_manager.get_schema(signature_class, "openai", cache_params)
32
-
33
- if cached_schema
34
- DSPy.logger.debug("Using cached schema for #{signature_class.name}")
35
- return cached_schema
36
- end
37
-
38
24
  # Get the output JSON schema from the signature class
39
25
  output_schema = signature_class.output_json_schema
40
26
 
27
+ # Convert oneOf to anyOf where safe, or raise error for unsupported cases
28
+ output_schema = convert_oneof_to_anyof_if_safe(output_schema)
29
+
41
30
  # Build the complete schema with OpenAI-specific modifications
42
31
  dspy_schema = {
43
32
  "$schema": "http://json-schema.org/draft-06/schema#",
@@ -59,7 +48,7 @@ module DSPy
59
48
  end
60
49
 
61
50
  # Wrap in OpenAI's required format
62
- result = {
51
+ {
63
52
  type: "json_schema",
64
53
  json_schema: {
65
54
  name: schema_name,
@@ -67,34 +56,75 @@ module DSPy
67
56
  schema: openai_schema
68
57
  }
69
58
  }
59
+ end
60
+
61
+ # Convert oneOf to anyOf if safe (discriminated unions), otherwise raise error
62
+ sig { params(schema: T.untyped).returns(T.untyped) }
63
+ def self.convert_oneof_to_anyof_if_safe(schema)
64
+ return schema unless schema.is_a?(Hash)
65
+
66
+ result = schema.dup
67
+
68
+ # Check if this schema has oneOf that we can safely convert
69
+ if result[:oneOf]
70
+ if all_have_discriminators?(result[:oneOf])
71
+ # Safe to convert - discriminators ensure mutual exclusivity
72
+ result[:anyOf] = result.delete(:oneOf).map { |s| convert_oneof_to_anyof_if_safe(s) }
73
+ else
74
+ # Unsafe conversion - raise error
75
+ raise DSPy::UnsupportedSchemaError.new(
76
+ "OpenAI structured outputs do not support oneOf schemas without discriminator fields. " \
77
+ "The schema contains union types that cannot be safely converted to anyOf. " \
78
+ "Please use enhanced_prompting strategy instead or add discriminator fields to union types."
79
+ )
80
+ end
81
+ end
82
+
83
+ # Recursively process nested schemas
84
+ if result[:properties].is_a?(Hash)
85
+ result[:properties] = result[:properties].transform_values { |v| convert_oneof_to_anyof_if_safe(v) }
86
+ end
70
87
 
71
- # Cache the result with same params
72
- cache_manager.cache_schema(signature_class, "openai", result, cache_params)
88
+ if result[:items].is_a?(Hash)
89
+ result[:items] = convert_oneof_to_anyof_if_safe(result[:items])
90
+ end
91
+
92
+ # Process arrays of schema items
93
+ if result[:items].is_a?(Array)
94
+ result[:items] = result[:items].map { |item|
95
+ item.is_a?(Hash) ? convert_oneof_to_anyof_if_safe(item) : item
96
+ }
97
+ end
98
+
99
+ # Process anyOf arrays (in case there are nested oneOf within anyOf)
100
+ if result[:anyOf].is_a?(Array)
101
+ result[:anyOf] = result[:anyOf].map { |item|
102
+ item.is_a?(Hash) ? convert_oneof_to_anyof_if_safe(item) : item
103
+ }
104
+ end
73
105
 
74
106
  result
75
107
  end
108
+
109
+ # Check if all schemas in a oneOf array have discriminator fields (const properties)
110
+ sig { params(schemas: T::Array[T.untyped]).returns(T::Boolean) }
111
+ def self.all_have_discriminators?(schemas)
112
+ schemas.all? do |schema|
113
+ next false unless schema.is_a?(Hash)
114
+ next false unless schema[:properties].is_a?(Hash)
115
+
116
+ # Check if any property has a const value (our discriminator pattern)
117
+ schema[:properties].any? { |_, prop| prop.is_a?(Hash) && prop[:const] }
118
+ end
119
+ end
76
120
 
77
121
  sig { params(model: String).returns(T::Boolean) }
78
122
  def self.supports_structured_outputs?(model)
79
- # Check cache first
80
- cache_manager = DSPy::LM.cache_manager
81
- cached_result = cache_manager.get_capability(model, "structured_outputs")
82
-
83
- if !cached_result.nil?
84
- DSPy.logger.debug("Using cached capability check for #{model}")
85
- return cached_result
86
- end
87
-
88
123
  # Extract base model name without provider prefix
89
124
  base_model = model.sub(/^openai\//, "")
90
125
 
91
126
  # Check if it's a supported model or a newer version
92
- result = STRUCTURED_OUTPUT_MODELS.any? { |supported| base_model.start_with?(supported) }
93
-
94
- # Cache the result
95
- cache_manager.cache_capability(model, "structured_outputs", result)
96
-
97
- result
127
+ STRUCTURED_OUTPUT_MODELS.any? { |supported| base_model.start_with?(supported) }
98
128
  end
99
129
 
100
130
  sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
@@ -226,8 +256,8 @@ module DSPy
226
256
  end
227
257
  end
228
258
 
229
- # Process oneOf/anyOf/allOf
230
- [:oneOf, :anyOf, :allOf].each do |key|
259
+ # Process anyOf/allOf (oneOf should be converted to anyOf by this point)
260
+ [:anyOf, :allOf].each do |key|
231
261
  if result[key].is_a?(Array)
232
262
  result[key] = result[key].map do |sub_schema|
233
263
  sub_schema.is_a?(Hash) ? add_additional_properties_recursively(sub_schema) : sub_schema
@@ -272,8 +302,8 @@ module DSPy
272
302
  max_depth = [max_depth, items_depth].max
273
303
  end
274
304
 
275
- # Check oneOf/anyOf/allOf
276
- [:oneOf, :anyOf, :allOf].each do |key|
305
+ # Check anyOf/allOf (oneOf should be converted to anyOf by this point)
306
+ [:anyOf, :allOf].each do |key|
277
307
  if schema[key].is_a?(Array)
278
308
  schema[key].each do |sub_schema|
279
309
  if sub_schema.is_a?(Hash)
@@ -291,8 +321,8 @@ module DSPy
291
321
  def self.contains_pattern_properties?(schema)
292
322
  return true if schema[:patternProperties]
293
323
 
294
- # Recursively check nested schemas
295
- [:properties, :items, :oneOf, :anyOf, :allOf].each do |key|
324
+ # Recursively check nested schemas (oneOf should be converted to anyOf by this point)
325
+ [:properties, :items, :anyOf, :allOf].each do |key|
296
326
  value = schema[key]
297
327
  case value
298
328
  when Hash
@@ -309,8 +339,8 @@ module DSPy
309
339
  def self.contains_conditional_schemas?(schema)
310
340
  return true if schema[:if] || schema[:then] || schema[:else]
311
341
 
312
- # Recursively check nested schemas
313
- [:properties, :items, :oneOf, :anyOf, :allOf].each do |key|
342
+ # Recursively check nested schemas (oneOf should be converted to anyOf by this point)
343
+ [:properties, :items, :anyOf, :allOf].each do |key|
314
344
  value = schema[key]
315
345
  case value
316
346
  when Hash
@@ -24,20 +24,27 @@ module DSPy
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)
27
+ # Handle O1 model restrictions - convert system messages to user messages
28
+ if o1_model?(model)
29
+ normalized_messages = handle_o1_messages(normalized_messages)
33
30
  end
34
31
 
35
32
  request_params = {
36
33
  model: model,
37
- messages: normalized_messages,
38
- temperature: temperature
34
+ messages: normalized_messages
39
35
  }
40
36
 
37
+ # Add temperature based on model capabilities
38
+ unless o1_model?(model)
39
+ temperature = case model
40
+ when /^gpt-5/, /^gpt-4o/
41
+ 1.0 # GPT-5 and GPT-4o models only support default temperature of 1.0
42
+ else
43
+ 0.0 # Near-deterministic for other models (0.0 no longer universally supported)
44
+ end
45
+ request_params[:temperature] = temperature
46
+ end
47
+
41
48
  # Add response format if provided by strategy
42
49
  if response_format
43
50
  request_params[:response_format] = response_format
@@ -148,6 +155,26 @@ module DSPy
148
155
  end
149
156
  end
150
157
  end
158
+
159
+ # Check if model is an O1 reasoning model (includes O1, O3, O4 series)
160
+ def o1_model?(model_name)
161
+ model_name.match?(/^o[134](-.*)?$/)
162
+ end
163
+
164
+ # Handle O1 model message restrictions
165
+ def handle_o1_messages(messages)
166
+ messages.map do |msg|
167
+ # Convert system messages to user messages for O1 models
168
+ if msg[:role] == 'system'
169
+ {
170
+ role: 'user',
171
+ content: "Instructions: #{msg[:content]}"
172
+ }
173
+ else
174
+ msg
175
+ end
176
+ end
177
+ end
151
178
  end
152
179
  end
153
180
  end
@@ -55,7 +55,7 @@ module DSPy
55
55
 
56
56
  # Let strategy handle the error first
57
57
  if strategy.handle_error(e)
58
- DSPy.logger.info("Strategy #{strategy.name} handled error, will try next strategy")
58
+ DSPy.logger.debug("Strategy #{strategy.name} handled error, trying next strategy")
59
59
  next # Try next strategy
60
60
  end
61
61
 
@@ -64,9 +64,18 @@ module DSPy
64
64
  retry_count += 1
65
65
  backoff_time = calculate_backoff(retry_count)
66
66
 
67
- DSPy.logger.warn(
68
- "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
69
- )
67
+ # Use debug for structured output strategies since they often have expected failures
68
+ log_level = ["openai_structured_output", "gemini_structured_output"].include?(strategy.name) ? :debug : :warn
69
+
70
+ if log_level == :debug
71
+ DSPy.logger.debug(
72
+ "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
73
+ )
74
+ else
75
+ DSPy.logger.warn(
76
+ "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
77
+ )
78
+ end
70
79
 
71
80
  Async::Task.current.sleep(backoff_time) if backoff_time > 0
72
81
  retry
@@ -101,8 +110,8 @@ module DSPy
101
110
  sig { params(strategy: Strategies::BaseStrategy).returns(Integer) }
102
111
  def max_retries_for_strategy(strategy)
103
112
  case strategy.name
104
- when "openai_structured_output"
105
- 1 # Structured outputs rarely benefit from retries
113
+ when "openai_structured_output", "gemini_structured_output"
114
+ 1 # Structured outputs rarely benefit from retries, most errors are permanent
106
115
  when "anthropic_extraction"
107
116
  2 # Anthropic can be a bit more variable
108
117
  else
@@ -31,13 +31,13 @@ module DSPy
31
31
 
32
32
  sig { override.params(messages: T::Array[T::Hash[Symbol, String]], request_params: T::Hash[Symbol, T.untyped]).void }
33
33
  def prepare_request(messages, request_params)
34
- # Convert signature to Gemini schema format
34
+ # Convert signature to Gemini JSON Schema format (supports oneOf/anyOf for unions)
35
35
  schema = DSPy::LM::Adapters::Gemini::SchemaConverter.to_gemini_format(signature_class)
36
36
 
37
- # Add generation_config for structured output
37
+ # Add generation_config for structured output using JSON Schema format
38
38
  request_params[:generation_config] = {
39
39
  response_mime_type: "application/json",
40
- response_schema: schema
40
+ response_json_schema: schema # Use JSON Schema format for proper union support
41
41
  }
42
42
  end
43
43
 
@@ -52,12 +52,25 @@ module DSPy
52
52
  def handle_error(error)
53
53
  # Handle Gemini-specific structured output errors
54
54
  error_msg = error.message.to_s.downcase
55
- if error_msg.include?("schema") || error_msg.include?("generation_config") || error_msg.include?("response_schema")
56
- # Log the error and return true to indicate we handled it
57
- # This allows fallback to another strategy
58
- DSPy.logger.warn("Gemini structured output failed: #{error.message}")
59
- true
55
+
56
+ # Check for permanent errors that shouldn't be retried
57
+ permanent_error_patterns = [
58
+ "schema",
59
+ "generation_config",
60
+ "response_schema",
61
+ "unknown name \"response_mime_type\"",
62
+ "unknown name \"response_schema\"",
63
+ "invalid json payload",
64
+ "no matching sse interaction found", # VCR test configuration issue
65
+ "cannot find field"
66
+ ]
67
+
68
+ if permanent_error_patterns.any? { |pattern| error_msg.include?(pattern) }
69
+ # These are permanent errors - no point retrying
70
+ DSPy.logger.debug("Gemini structured output failed (permanent error, skipping retries): #{error.message}")
71
+ true # Skip retries and try next strategy
60
72
  else
73
+ # Unknown error - let retry logic handle it
61
74
  false
62
75
  end
63
76
  end
data/lib/dspy/lm.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'async'
5
+ require 'securerandom'
5
6
 
6
7
  # Load adapter infrastructure
7
8
  require_relative 'lm/errors'
@@ -42,7 +43,17 @@ module DSPy
42
43
  end
43
44
 
44
45
  def chat(inference_module, input_values, &block)
46
+ # Capture the current DSPy context before entering Sync block
47
+ parent_context = DSPy::Context.current.dup
48
+
45
49
  Sync do
50
+ # Properly restore the context in the new fiber created by Sync
51
+ # We need to set both thread and fiber storage for the new context system
52
+ thread_key = :"dspy_context_#{Thread.current.object_id}"
53
+ Thread.current[thread_key] = parent_context
54
+ Thread.current[:dspy_context] = parent_context # Keep for backward compatibility
55
+ Fiber[:dspy_context] = parent_context
56
+
46
57
  signature_class = inference_module.signature_class
47
58
 
48
59
  # Build messages from inference module
@@ -225,7 +236,7 @@ module DSPy
225
236
  # Wrap LLM call in span tracking
226
237
  response = DSPy::Context.with_span(
227
238
  operation: 'llm.generate',
228
- 'langfuse.observation.type' => 'generation',
239
+ **DSPy::ObservationType::Generation.langfuse_attributes,
229
240
  'langfuse.observation.input' => input_json,
230
241
  'gen_ai.system' => provider,
231
242
  'gen_ai.request.model' => model,
@@ -267,11 +278,26 @@ module DSPy
267
278
  token_usage = extract_token_usage(response)
268
279
 
269
280
  if token_usage.any?
270
- DSPy.log('lm.tokens', **token_usage.merge({
281
+ event_attributes = token_usage.merge({
271
282
  'gen_ai.system' => provider,
272
283
  'gen_ai.request.model' => model,
273
284
  'dspy.signature' => signature_class_name
274
- }))
285
+ })
286
+
287
+ # Add timing and request correlation if available
288
+ request_id = Thread.current[:dspy_request_id]
289
+ start_time = Thread.current[:dspy_request_start_time]
290
+
291
+ if request_id
292
+ event_attributes['request_id'] = request_id
293
+ end
294
+
295
+ if start_time
296
+ duration = Time.now - start_time
297
+ event_attributes['duration'] = duration
298
+ end
299
+
300
+ DSPy.event('lm.tokens', event_attributes)
275
301
  end
276
302
 
277
303
  token_usage
@@ -341,15 +367,32 @@ module DSPy
341
367
  end
342
368
 
343
369
  def execute_raw_chat(messages, &streaming_block)
344
- response = instrument_lm_request(messages, 'RawPrompt') do
345
- # Convert messages to hash format for adapter
346
- hash_messages = messages_to_hash_array(messages)
347
- # Direct adapter call, no strategies or JSON parsing
348
- adapter.chat(messages: hash_messages, signature: nil, &streaming_block)
349
- end
370
+ # Generate unique request ID for tracking
371
+ request_id = SecureRandom.hex(8)
372
+ start_time = Time.now
373
+
374
+ # Store request context for correlation
375
+ Thread.current[:dspy_request_id] = request_id
376
+ Thread.current[:dspy_request_start_time] = start_time
350
377
 
351
- # Return raw response content, not parsed JSON
352
- response.content
378
+ begin
379
+ response = instrument_lm_request(messages, 'RawPrompt') do
380
+ # Convert messages to hash format for adapter
381
+ hash_messages = messages_to_hash_array(messages)
382
+ # Direct adapter call, no strategies or JSON parsing
383
+ adapter.chat(messages: hash_messages, signature: nil, &streaming_block)
384
+ end
385
+
386
+ # Emit the standard lm.tokens event (consistent with other LM calls)
387
+ emit_token_usage(response, 'RawPrompt')
388
+
389
+ # Return raw response content, not parsed JSON
390
+ response.content
391
+ ensure
392
+ # Clean up thread-local storage
393
+ Thread.current[:dspy_request_id] = nil
394
+ Thread.current[:dspy_request_start_time] = nil
395
+ end
353
396
  end
354
397
 
355
398
  # Convert messages to normalized Message objects
@@ -36,17 +36,33 @@ module DSPy
36
36
 
37
37
  sig { override.params(text: String).returns(T::Array[Float]) }
38
38
  def embed(text)
39
- ensure_ready!
40
-
41
- # Preprocess text
42
- cleaned_text = preprocess_text(text)
43
-
44
- # Generate embedding
45
- result = @model.call(cleaned_text)
46
-
47
- # Extract embedding array and normalize
48
- embedding = result.first.to_a
49
- normalize_vector(embedding)
39
+ DSPy::Context.with_span(
40
+ operation: 'embedding.generate',
41
+ **DSPy::ObservationType::Embedding.langfuse_attributes,
42
+ 'embedding.model' => @model_name,
43
+ 'embedding.input' => text[0..200], # Truncate for logging
44
+ 'embedding.input_length' => text.length
45
+ ) do |span|
46
+ ensure_ready!
47
+
48
+ # Preprocess text
49
+ cleaned_text = preprocess_text(text)
50
+
51
+ # Generate embedding
52
+ result = @model.call(cleaned_text)
53
+
54
+ # Extract embedding array and normalize
55
+ embedding = result.first.to_a
56
+ normalized = normalize_vector(embedding)
57
+
58
+ # Add embedding metadata to span
59
+ if span
60
+ span.set_attribute('embedding.dimension', normalized.length)
61
+ span.set_attribute('embedding.magnitude', Math.sqrt(normalized.sum { |x| x * x }))
62
+ end
63
+
64
+ normalized
65
+ end
50
66
  end
51
67
 
52
68
  sig { override.params(texts: T::Array[String]).returns(T::Array[T::Array[Float]]) }
@@ -95,15 +95,32 @@ module DSPy
95
95
  # Semantic search using embeddings
96
96
  sig { params(query: String, user_id: T.nilable(String), limit: T.nilable(Integer), threshold: T.nilable(Float)).returns(T::Array[MemoryRecord]) }
97
97
  def search_memories(query, user_id: nil, limit: 10, threshold: 0.5)
98
- # Generate embedding for the query
99
- query_embedding = @embedding_engine.embed(query)
100
-
101
- # Perform vector search if supported
102
- if @store.supports_vector_search?
103
- @store.vector_search(query_embedding, user_id: user_id, limit: limit, threshold: threshold)
104
- else
105
- # Fallback to text search
106
- @store.search(query, user_id: user_id, limit: limit)
98
+ DSPy::Context.with_span(
99
+ operation: 'memory.search',
100
+ **DSPy::ObservationType::Retriever.langfuse_attributes,
101
+ 'retriever.query' => query,
102
+ 'retriever.user_id' => user_id,
103
+ 'retriever.limit' => limit,
104
+ 'retriever.threshold' => threshold
105
+ ) do |span|
106
+ # Generate embedding for the query
107
+ query_embedding = @embedding_engine.embed(query)
108
+
109
+ # Perform vector search if supported
110
+ results = if @store.supports_vector_search?
111
+ @store.vector_search(query_embedding, user_id: user_id, limit: limit, threshold: threshold)
112
+ else
113
+ # Fallback to text search
114
+ @store.search(query, user_id: user_id, limit: limit)
115
+ end
116
+
117
+ # Add retrieval results to span
118
+ if span
119
+ span.set_attribute('retriever.results_count', results.length)
120
+ span.set_attribute('retriever.results', results.map { |r| { id: r.id, content: r.content[0..100] } }.to_json)
121
+ end
122
+
123
+ results
107
124
  end
108
125
  end
109
126
 
@@ -34,6 +34,8 @@ module DSPy
34
34
  coerce_union_value(value, prop_type)
35
35
  when ->(type) { array_type?(type) }
36
36
  coerce_array_value(value, prop_type)
37
+ when ->(type) { hash_type?(type) }
38
+ coerce_hash_value(value, prop_type)
37
39
  when ->(type) { enum_type?(type) }
38
40
  extract_enum_class(prop_type).deserialize(value)
39
41
  when Float, ->(type) { simple_type_match?(type, Float) }
@@ -88,6 +90,12 @@ module DSPy
88
90
  true
89
91
  end
90
92
 
93
+ # Checks if a type is a hash type
94
+ sig { params(type: T.untyped).returns(T::Boolean) }
95
+ def hash_type?(type)
96
+ type.is_a?(T::Types::TypedHash)
97
+ end
98
+
91
99
  # Checks if a type is a struct type
92
100
  sig { params(type: T.untyped).returns(T::Boolean) }
93
101
  def struct_type?(type)
@@ -124,6 +132,28 @@ module DSPy
124
132
  value.map { |element| coerce_value_to_type(element, element_type) }
125
133
  end
126
134
 
135
+ # Coerces a hash value, converting keys and values as needed
136
+ sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
137
+ def coerce_hash_value(value, prop_type)
138
+ return value unless value.is_a?(Hash)
139
+ return value unless prop_type.is_a?(T::Types::TypedHash)
140
+
141
+ key_type = prop_type.keys
142
+ value_type = prop_type.values
143
+
144
+ # Convert string keys to enum instances if key_type is an enum
145
+ result = if enum_type?(key_type)
146
+ enum_class = extract_enum_class(key_type)
147
+ value.transform_keys { |k| enum_class.deserialize(k.to_s) }
148
+ else
149
+ # For non-enum keys, coerce them to the expected type
150
+ value.transform_keys { |k| coerce_value_to_type(k, key_type) }
151
+ end
152
+
153
+ # Coerce values to their expected types
154
+ result.transform_values { |v| coerce_value_to_type(v, value_type) }
155
+ end
156
+
127
157
  # Coerces a struct value from a hash
128
158
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
129
159
  def coerce_struct_value(value, prop_type)
data/lib/dspy/module.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'dry-configurable'
5
+ require_relative 'context'
5
6
 
6
7
  module DSPy
7
8
  class Module
@@ -21,8 +22,25 @@ module DSPy
21
22
  .returns(T.type_parameter(:O))
22
23
  end
23
24
  def forward(**input_values)
24
- # Cast the result of forward_untyped to the expected output type
25
- T.cast(forward_untyped(**input_values), T.type_parameter(:O))
25
+ # Create span for this module's execution
26
+ observation_type = DSPy::ObservationType.for_module_class(self.class)
27
+ DSPy::Context.with_span(
28
+ operation: "#{self.class.name}.forward",
29
+ **observation_type.langfuse_attributes,
30
+ 'langfuse.observation.input' => input_values.to_json,
31
+ 'dspy.module' => self.class.name
32
+ ) do |span|
33
+ result = forward_untyped(**input_values)
34
+
35
+ # Add output to span
36
+ if span && result
37
+ output_json = result.respond_to?(:to_h) ? result.to_h.to_json : result.to_json rescue result.to_s
38
+ span.set_attribute('langfuse.observation.output', output_json)
39
+ end
40
+
41
+ # Cast the result of forward_untyped to the expected output type
42
+ T.cast(result, T.type_parameter(:O))
43
+ end
26
44
  end
27
45
 
28
46
  # The implementation method that subclasses must override
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ # Langfuse observation types as a T::Enum for type safety
7
+ # Maps to the official Langfuse observation types: https://langfuse.com/docs/observability/features/observation-types
8
+ class ObservationType < T::Enum
9
+ enums do
10
+ # LLM generation calls - used for direct model inference
11
+ Generation = new('generation')
12
+
13
+ # Agent operations - decision-making processes using tools/LLM guidance
14
+ Agent = new('agent')
15
+
16
+ # External tool calls (APIs, functions, etc.)
17
+ Tool = new('tool')
18
+
19
+ # Chains linking different application steps/components
20
+ Chain = new('chain')
21
+
22
+ # Data retrieval operations (vector stores, databases, memory search)
23
+ Retriever = new('retriever')
24
+
25
+ # Embedding generation calls
26
+ Embedding = new('embedding')
27
+
28
+ # Functions that assess quality/relevance of outputs
29
+ Evaluator = new('evaluator')
30
+
31
+ # Generic spans for durations of work units
32
+ Span = new('span')
33
+
34
+ # Discrete events/moments in time
35
+ Event = new('event')
36
+ end
37
+
38
+ # Get the appropriate observation type for a DSPy module class
39
+ sig { params(module_class: T.class_of(DSPy::Module)).returns(ObservationType) }
40
+ def self.for_module_class(module_class)
41
+ case module_class.name
42
+ when /ReAct/, /CodeAct/
43
+ Agent
44
+ when /ChainOfThought/
45
+ Chain
46
+ when /Evaluator/
47
+ Evaluator
48
+ else
49
+ Span
50
+ end
51
+ end
52
+
53
+ # Returns the langfuse attribute key and value as an array
54
+ sig { returns([String, String]) }
55
+ def langfuse_attribute
56
+ ['langfuse.observation.type', serialize]
57
+ end
58
+
59
+ # Returns a hash with the langfuse attribute for easy merging
60
+ sig { returns(T::Hash[String, String]) }
61
+ def langfuse_attributes
62
+ { 'langfuse.observation.type' => serialize }
63
+ end
64
+ end
65
+ end