dspy 0.19.1 → 0.20.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.
@@ -26,12 +26,30 @@ module DSPy
26
26
  'claude-3-5-haiku-20241022'
27
27
  ].freeze
28
28
 
29
+ # Gemini vision-capable models (all Gemini models support vision)
30
+ # Based on official Google AI API documentation (March 2025)
31
+ GEMINI_VISION_MODELS = [
32
+ # Gemini 2.5 series (2025)
33
+ 'gemini-2.5-pro',
34
+ 'gemini-2.5-flash',
35
+ 'gemini-2.5-flash-lite',
36
+ # Gemini 2.0 series (2024-2025)
37
+ 'gemini-2.0-flash',
38
+ 'gemini-2.0-flash-lite',
39
+ # Gemini 1.5 series
40
+ 'gemini-1.5-pro',
41
+ 'gemini-1.5-flash',
42
+ 'gemini-1.5-flash-8b'
43
+ ].freeze
44
+
29
45
  def self.supports_vision?(provider, model)
30
46
  case provider.to_s.downcase
31
47
  when 'openai'
32
48
  OPENAI_VISION_MODELS.any? { |m| model.include?(m) }
33
49
  when 'anthropic'
34
50
  ANTHROPIC_VISION_MODELS.any? { |m| model.include?(m) }
51
+ when 'gemini'
52
+ GEMINI_VISION_MODELS.any? { |m| model.include?(m) }
35
53
  else
36
54
  false
37
55
  end
@@ -49,6 +67,8 @@ module DSPy
49
67
  OPENAI_VISION_MODELS
50
68
  when 'anthropic'
51
69
  ANTHROPIC_VISION_MODELS
70
+ when 'gemini'
71
+ GEMINI_VISION_MODELS
52
72
  else
53
73
  []
54
74
  end
data/lib/dspy/lm.rb CHANGED
@@ -14,6 +14,7 @@ require_relative 'lm/adapter_factory'
14
14
  require_relative 'lm/adapters/openai_adapter'
15
15
  require_relative 'lm/adapters/anthropic_adapter'
16
16
  require_relative 'lm/adapters/ollama_adapter'
17
+ require_relative 'lm/adapters/gemini_adapter'
17
18
 
18
19
  # Load strategy system
19
20
  require_relative 'lm/strategy_selector'
@@ -232,10 +233,10 @@ module DSPy
232
233
  usage = result.usage
233
234
  DSPy.log('span.attributes',
234
235
  span_id: DSPy::Context.current[:span_stack].last,
235
- 'gen_ai.response.model' => result.respond_to?(:model) ? result.model : nil,
236
- 'gen_ai.usage.prompt_tokens' => usage.respond_to?(:input_tokens) ? usage.input_tokens : nil,
237
- 'gen_ai.usage.completion_tokens' => usage.respond_to?(:output_tokens) ? usage.output_tokens : nil,
238
- 'gen_ai.usage.total_tokens' => usage.respond_to?(:total_tokens) ? usage.total_tokens : nil
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
239
240
  )
240
241
  end
241
242
 
@@ -80,7 +80,8 @@ module DSPy
80
80
  def extract_type_from_prop(prop)
81
81
  case prop
82
82
  when Hash
83
- prop[:type]
83
+ # Prefer type_object for nilable types, fallback to type
84
+ prop[:type_object] || prop[:type]
84
85
  when Array
85
86
  # Handle [Type, description] format
86
87
  prop.first
@@ -94,7 +95,18 @@ module DSPy
94
95
  def extract_options_from_prop(prop)
95
96
  case prop
96
97
  when Hash
97
- prop.except(:type, :type_object, :accessor_key, :sensitivity, :redaction)
98
+ # Preserve important flags like fully_optional for nilable types
99
+ extracted = prop.except(:type, :type_object, :accessor_key, :sensitivity, :redaction, :setter_proc, :value_validate_proc, :serialized_form, :need_nil_read_check, :immutable, :pii, :extra)
100
+
101
+ # Handle default values properly
102
+ if prop[:default]
103
+ extracted[:default] = prop[:default]
104
+ elsif prop[:fully_optional]
105
+ # For fully optional fields (nilable), set default to nil
106
+ extracted[:default] = nil
107
+ end
108
+
109
+ extracted
98
110
  else
99
111
  {}
100
112
  end
data/lib/dspy/module.rb CHANGED
@@ -49,10 +49,10 @@ module DSPy
49
49
  forward_untyped(**input_values)
50
50
  end
51
51
 
52
- # Get the configured LM for this instance, falling back to global
52
+ # Get the configured LM for this instance, checking fiber-local context first
53
53
  sig { returns(T.untyped) }
54
54
  def lm
55
- config.lm || DSPy.config.lm
55
+ config.lm || DSPy.current_lm
56
56
  end
57
57
  end
58
58
  end
data/lib/dspy/predict.rb CHANGED
@@ -59,6 +59,42 @@ module DSPy
59
59
  @prompt = Prompt.from_signature(signature_class)
60
60
  end
61
61
 
62
+ # Reconstruct program from serialized hash
63
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.attached_class) }
64
+ def self.from_h(data)
65
+ state = data[:state]
66
+ raise ArgumentError, "Missing state in serialized data" unless state
67
+
68
+ signature_class_name = state[:signature_class]
69
+ signature_class = Object.const_get(signature_class_name)
70
+ program = new(signature_class)
71
+
72
+ # Restore instruction if available
73
+ if state[:instruction]
74
+ program = program.with_instruction(state[:instruction])
75
+ end
76
+
77
+ # Restore examples if available
78
+ few_shot_examples = state[:few_shot_examples]
79
+ if few_shot_examples && !few_shot_examples.empty?
80
+ # Convert hash examples back to FewShotExample objects
81
+ examples = few_shot_examples.map do |ex|
82
+ if ex.is_a?(Hash)
83
+ DSPy::FewShotExample.new(
84
+ input: ex[:input],
85
+ output: ex[:output],
86
+ reasoning: ex[:reasoning]
87
+ )
88
+ else
89
+ ex
90
+ end
91
+ end
92
+ program = program.with_examples(examples)
93
+ end
94
+
95
+ program
96
+ end
97
+
62
98
  # Backward compatibility methods - delegate to prompt object
63
99
  sig { returns(String) }
64
100
  def system_signature
@@ -159,7 +195,11 @@ module DSPy
159
195
  begin
160
196
  combined_struct = create_combined_struct_class
161
197
  all_attributes = input_values.merge(output_attributes)
162
- combined_struct.new(**all_attributes)
198
+
199
+ # Preprocess nilable attributes before struct instantiation
200
+ processed_attributes = preprocess_nilable_attributes(all_attributes, combined_struct)
201
+
202
+ combined_struct.new(**processed_attributes)
163
203
  rescue ArgumentError => e
164
204
  raise PredictionInvalidError.new({ output: e.message })
165
205
  rescue TypeError => e
@@ -195,5 +235,36 @@ module DSPy
195
235
 
196
236
  output_attributes
197
237
  end
238
+
239
+ # Preprocesses attributes to handle nilable fields properly before struct instantiation
240
+ sig { params(attributes: T::Hash[Symbol, T.untyped], struct_class: T.class_of(T::Struct)).returns(T::Hash[Symbol, T.untyped]) }
241
+ def preprocess_nilable_attributes(attributes, struct_class)
242
+ processed = attributes.dup
243
+ struct_props = struct_class.props
244
+
245
+ # Process each attribute based on its type in the struct
246
+ processed.each do |key, value|
247
+ prop_info = struct_props[key]
248
+ next unless prop_info
249
+
250
+ prop_type = prop_info[:type_object] || prop_info[:type]
251
+ next unless prop_type
252
+
253
+ # For nilable fields with nil values, ensure proper handling
254
+ if value.nil? && is_nilable_type?(prop_type)
255
+ # For nilable fields, nil is valid - keep it as is
256
+ next
257
+ elsif value.nil? && prop_info[:fully_optional]
258
+ # For fully optional fields, nil is valid - keep it as is
259
+ next
260
+ elsif value.nil? && prop_info[:default]
261
+ # Use default value if available
262
+ default_value = prop_info[:default]
263
+ processed[key] = default_value.is_a?(Proc) ? default_value.call : default_value
264
+ end
265
+ end
266
+
267
+ processed
268
+ end
198
269
  end
199
270
  end
@@ -123,7 +123,8 @@ module DSPy
123
123
  end
124
124
  elsif is_enum_type?(prop_type) && value.is_a?(String)
125
125
  # Convert string to enum
126
- converted[key] = prop_type.raw_type.deserialize(value)
126
+ enum_class = extract_enum_class(prop_type)
127
+ converted[key] = enum_class.deserialize(value)
127
128
  elsif value.is_a?(Hash) && needs_struct_conversion?(prop_type)
128
129
  # Regular struct field that needs conversion
129
130
  converted[key] = convert_to_struct(value, prop_type)
@@ -188,18 +189,61 @@ module DSPy
188
189
  sig { params(type: T.untyped).returns(T::Boolean) }
189
190
  def is_enum_type?(type)
190
191
  return false if type.nil?
191
- return false unless type.is_a?(T::Types::Simple)
192
192
 
193
- begin
194
- raw_type = type.raw_type
195
- return false unless raw_type.is_a?(Class)
196
- result = raw_type < T::Enum
197
- return result == true # Force conversion to boolean
198
- rescue StandardError
193
+ case type
194
+ when T::Types::Simple
195
+ # Handle regular enum types
196
+ begin
197
+ raw_type = type.raw_type
198
+ return false unless raw_type.is_a?(Class)
199
+ result = raw_type < T::Enum
200
+ return result == true # Force conversion to boolean
201
+ rescue StandardError
202
+ return false
203
+ end
204
+ when T::Private::Types::SimplePairUnion, T::Types::Union
205
+ # Handle T.nilable enum types
206
+ # Find the non-nil type and check if it's an enum
207
+ non_nil_types = if type.respond_to?(:types)
208
+ type.types.reject { |t| t.respond_to?(:raw_type) && t.raw_type == NilClass }
209
+ else
210
+ []
211
+ end
212
+
213
+ # For nilable types, we expect exactly one non-nil type
214
+ return false unless non_nil_types.size == 1
215
+
216
+ non_nil_type = non_nil_types.first
217
+ return is_enum_type?(non_nil_type) # Recursively check
218
+ else
199
219
  return false
200
220
  end
201
221
  end
202
222
 
223
+ sig { params(type: T.untyped).returns(T.untyped) }
224
+ def extract_enum_class(type)
225
+ case type
226
+ when T::Types::Simple
227
+ # Regular enum type
228
+ type.raw_type
229
+ when T::Private::Types::SimplePairUnion, T::Types::Union
230
+ # Nilable enum type - find the non-nil type
231
+ non_nil_types = if type.respond_to?(:types)
232
+ type.types.reject { |t| t.respond_to?(:raw_type) && t.raw_type == NilClass }
233
+ else
234
+ []
235
+ end
236
+
237
+ if non_nil_types.size == 1
238
+ extract_enum_class(non_nil_types.first)
239
+ else
240
+ raise ArgumentError, "Unable to extract enum class from complex union type: #{type.inspect}"
241
+ end
242
+ else
243
+ raise ArgumentError, "Not an enum type: #{type.inspect}"
244
+ end
245
+ end
246
+
203
247
  sig { params(union_type: T::Types::Union, discriminator_type: T.untyped).returns(T::Hash[String, T.untyped]) }
204
248
  def build_type_mapping_from_union(union_type, discriminator_type)
205
249
  mapping = {}
@@ -303,7 +347,12 @@ module DSPy
303
347
  def needs_struct_conversion?(type)
304
348
  case type
305
349
  when T::Types::Simple
306
- type.raw_type < T::Struct
350
+ # Use !! to convert nil result of < comparison to false
351
+ begin
352
+ !!(type.raw_type < T::Struct)
353
+ rescue
354
+ false
355
+ end
307
356
  when T::Types::Union
308
357
  # Check if any type in the union is a struct
309
358
  type.types.any? { |t| needs_struct_conversion?(t) }
@@ -352,7 +401,7 @@ module DSPy
352
401
  end
353
402
  begin
354
403
  struct_class.new(**converted_hash)
355
- rescue => e
404
+ rescue
356
405
  # Return original value if conversion fails
357
406
  value
358
407
  end
@@ -172,15 +172,35 @@ module DSPy
172
172
  sig { params(struct_class: T.class_of(T::Struct)).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
173
173
  def extract_field_info(struct_class)
174
174
  struct_class.props.map do |name, prop_info|
175
- {
175
+ field_info = {
176
176
  name: name,
177
177
  type: prop_info[:type].to_s,
178
178
  description: prop_info[:description] || "",
179
179
  required: !prop_info[:rules]&.any? { |rule| rule.is_a?(T::Props::NilableRules) }
180
180
  }
181
+
182
+ # Extract enum values if this is an enum type
183
+ if enum_values = extract_enum_values(prop_info[:type])
184
+ field_info[:enum_values] = enum_values
185
+ field_info[:is_enum] = true
186
+ end
187
+
188
+ field_info
189
+ end
190
+ end
191
+
192
+ # Extract enum values from a type if it's an enum
193
+ sig { params(type: T.untyped).returns(T.nilable(T::Array[String])) }
194
+ def extract_enum_values(type)
195
+ # Handle T::Enum types
196
+ if type.is_a?(Class) && type < T::Enum
197
+ type.values.map(&:serialize)
198
+ else
199
+ nil
181
200
  end
182
201
  end
183
202
 
203
+
184
204
  # Analyze patterns in training examples
185
205
  sig { params(examples: T::Array[T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
186
206
  def analyze_example_patterns(examples)
@@ -364,8 +384,12 @@ module DSPy
364
384
  context_parts << "Task: #{signature_class.description}" if @config.use_task_description
365
385
 
366
386
  if @config.use_input_output_analysis
367
- context_parts << "Input fields: #{analysis[:input_fields].map { |f| "#{f[:name]} (#{f[:type]})" }.join(', ')}"
368
- context_parts << "Output fields: #{analysis[:output_fields].map { |f| "#{f[:name]} (#{f[:type]})" }.join(', ')}"
387
+ # Build detailed field descriptions including enum values
388
+ input_descriptions = analysis[:input_fields].map { |f| format_field_description(f) }
389
+ output_descriptions = analysis[:output_fields].map { |f| format_field_description(f) }
390
+
391
+ context_parts << "Input fields: #{input_descriptions.join(', ')}"
392
+ context_parts << "Output fields: #{output_descriptions.join(', ')}"
369
393
  end
370
394
 
371
395
  if analysis[:common_themes] && analysis[:common_themes].any?
@@ -379,6 +403,17 @@ module DSPy
379
403
  context_parts.join("\n")
380
404
  end
381
405
 
406
+ # Format field description with enum values if applicable
407
+ sig { params(field: T::Hash[Symbol, T.untyped]).returns(String) }
408
+ def format_field_description(field)
409
+ base = "#{field[:name]} (#{field[:type]})"
410
+ if field[:is_enum] && field[:enum_values] && !field[:enum_values].empty?
411
+ "#{base} [values: #{field[:enum_values].join(', ')}]"
412
+ else
413
+ base
414
+ end
415
+ end
416
+
382
417
  # Build requirements text for instruction generation
383
418
  sig { params(analysis: T::Hash[Symbol, T.untyped]).returns(String) }
384
419
  def build_requirements_text(analysis)
data/lib/dspy/re_act.rb CHANGED
@@ -112,8 +112,16 @@ module DSPy
112
112
  # Use the enhanced output struct with ReAct fields
113
113
  @output_struct_class = enhanced_output_struct
114
114
 
115
+ # Store original signature name
116
+ @original_signature_name = signature_class.name
117
+
115
118
  class << self
116
- attr_reader :input_struct_class, :output_struct_class
119
+ attr_reader :input_struct_class, :output_struct_class, :original_signature_name
120
+
121
+ # Override name to return the original signature name
122
+ def name
123
+ @original_signature_name || super
124
+ end
117
125
  end
118
126
  end
119
127
 
@@ -123,9 +131,6 @@ module DSPy
123
131
 
124
132
  sig { params(kwargs: T.untyped).returns(T.untyped).override }
125
133
  def forward(**kwargs)
126
- lm = config.lm || DSPy.config.lm
127
- available_tools = @tools.keys
128
-
129
134
  # Validate input
130
135
  input_struct = @original_signature_class.input_struct_class.new(**kwargs)
131
136
 
@@ -257,17 +257,87 @@ module DSPy
257
257
  # Add a more explicit description of the expected structure
258
258
  description: "A mapping where keys are #{key_schema[:type]}s and values are #{value_schema[:description] || value_schema[:type]}s"
259
259
  }
260
+ elsif type.class.name == "T::Private::Types::SimplePairUnion"
261
+ # Handle T.nilable types (T::Private::Types::SimplePairUnion)
262
+ # This is the actual implementation of T.nilable(SomeType)
263
+ has_nil = type.respond_to?(:types) && type.types.any? do |t|
264
+ (t.respond_to?(:raw_type) && t.raw_type == NilClass) ||
265
+ (t.respond_to?(:name) && t.name == "NilClass")
266
+ end
267
+
268
+ if has_nil
269
+ # Find the non-nil type
270
+ non_nil_type = type.types.find do |t|
271
+ !(t.respond_to?(:raw_type) && t.raw_type == NilClass) &&
272
+ !(t.respond_to?(:name) && t.name == "NilClass")
273
+ end
274
+
275
+ if non_nil_type
276
+ base_schema = type_to_json_schema(non_nil_type)
277
+ if base_schema[:type].is_a?(String)
278
+ # Convert single type to array with null
279
+ { type: [base_schema[:type], "null"] }.merge(base_schema.except(:type))
280
+ else
281
+ # For complex schemas, use anyOf to allow null
282
+ { anyOf: [base_schema, { type: "null" }] }
283
+ end
284
+ else
285
+ { type: "string" } # Fallback
286
+ end
287
+ else
288
+ # Not nilable SimplePairUnion - this is a regular T.any() union
289
+ # Generate oneOf schema for all types
290
+ if type.respond_to?(:types) && type.types.length > 1
291
+ {
292
+ oneOf: type.types.map { |t| type_to_json_schema(t) },
293
+ description: "Union of multiple types"
294
+ }
295
+ else
296
+ # Single type or fallback
297
+ first_type = type.respond_to?(:types) ? type.types.first : type
298
+ type_to_json_schema(first_type)
299
+ end
300
+ end
260
301
  elsif type.is_a?(T::Types::Union)
261
- # For optional types (T.nilable), just use the non-nil type
302
+ # Check if this is a nilable type (contains NilClass)
303
+ is_nilable = type.types.any? { |t| t == T::Utils.coerce(NilClass) }
262
304
  non_nil_types = type.types.reject { |t| t == T::Utils.coerce(NilClass) }
263
- if non_nil_types.size == 1
305
+
306
+ # Special case: check if we have TrueClass + FalseClass (T.nilable(T::Boolean))
307
+ if non_nil_types.size == 2 && is_nilable
308
+ true_class_type = non_nil_types.find { |t| t.respond_to?(:raw_type) && t.raw_type == TrueClass }
309
+ false_class_type = non_nil_types.find { |t| t.respond_to?(:raw_type) && t.raw_type == FalseClass }
310
+
311
+ if true_class_type && false_class_type
312
+ # This is T.nilable(T::Boolean) - treat as nilable boolean
313
+ return { type: ["boolean", "null"] }
314
+ end
315
+ end
316
+
317
+ if non_nil_types.size == 1 && is_nilable
318
+ # This is T.nilable(SomeType) - generate proper schema with null allowed
319
+ base_schema = type_to_json_schema(non_nil_types.first)
320
+ if base_schema[:type].is_a?(String)
321
+ # Convert single type to array with null
322
+ { type: [base_schema[:type], "null"] }.merge(base_schema.except(:type))
323
+ else
324
+ # For complex schemas, use anyOf to allow null
325
+ { anyOf: [base_schema, { type: "null" }] }
326
+ end
327
+ elsif non_nil_types.size == 1
328
+ # Non-nilable single type union (shouldn't happen in practice)
264
329
  type_to_json_schema(non_nil_types.first)
265
330
  elsif non_nil_types.size > 1
266
331
  # Handle complex unions with oneOf for better JSON schema compliance
267
- {
332
+ base_schema = {
268
333
  oneOf: non_nil_types.map { |t| type_to_json_schema(t) },
269
334
  description: "Union of multiple types"
270
335
  }
336
+ if is_nilable
337
+ # Add null as an option for complex nilable unions
338
+ base_schema[:oneOf] << { type: "null" }
339
+ end
340
+ base_schema
271
341
  else
272
342
  { type: "string" } # Fallback for complex unions
273
343
  end
@@ -90,18 +90,39 @@ module DSPy
90
90
 
91
91
  sig { params(program: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
92
92
  def serialize_program(program)
93
- # Basic serialization - can be enhanced for specific program types
94
- {
95
- class_name: program.class.name,
96
- state: extract_program_state(program)
97
- }
93
+ # Basic serialization
94
+ if program.is_a?(Hash)
95
+ # Already serialized - return as-is to preserve state
96
+ program
97
+ else
98
+ # Real program object - serialize it
99
+ {
100
+ class_name: program.class.name,
101
+ state: extract_program_state(program)
102
+ }
103
+ end
98
104
  end
99
105
 
100
- sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
106
+ sig { params(data: T.untyped).returns(T.untyped) }
101
107
  def self.deserialize_program(data)
102
- # Basic deserialization - would need enhancement for complex programs
103
- # For now, return the serialized state
104
- data
108
+ # Ensure data is a Hash
109
+ unless data.is_a?(Hash)
110
+ raise ArgumentError, "Expected Hash for program data, got #{data.class.name}"
111
+ end
112
+
113
+ # Get class name from the serialized data
114
+ class_name = data[:class_name]
115
+ raise ArgumentError, "Missing class_name in serialized data" unless class_name
116
+
117
+ # Get the class constant
118
+ program_class = Object.const_get(class_name)
119
+
120
+ # Use the class's from_h method
121
+ unless program_class.respond_to?(:from_h)
122
+ raise ArgumentError, "Class #{class_name} does not support deserialization (missing from_h method)"
123
+ end
124
+
125
+ program_class.from_h(data)
105
126
  end
106
127
 
107
128
  sig { params(program: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
@@ -310,6 +331,20 @@ module DSPy
310
331
  { programs: [], summary: { total_programs: 0, avg_score: 0.0 } }
311
332
  end
312
333
 
334
+ # Extract signature class name from program object
335
+ unless saved_program.program.respond_to?(:signature_class)
336
+ raise ArgumentError, "Program #{saved_program.program.class.name} does not respond to signature_class method"
337
+ end
338
+
339
+ signature_class_name = saved_program.program.signature_class.name
340
+
341
+ if signature_class_name.nil? || signature_class_name.empty?
342
+ raise(
343
+ "Program #{saved_program.program.class.name} has a signature class that does not provide a name.\n" \
344
+ "Ensure the signature class responds to #name or that signature_class_name is stored in program state."
345
+ )
346
+ end
347
+
313
348
  # Add or update program entry
314
349
  program_entry = {
315
350
  program_id: saved_program.program_id,
@@ -317,7 +352,7 @@ module DSPy
317
352
  best_score: saved_program.optimization_result[:best_score_value],
318
353
  score_name: saved_program.optimization_result[:best_score_name],
319
354
  optimizer: saved_program.optimization_result[:metadata]&.dig(:optimizer),
320
- signature_class: saved_program.metadata[:signature_class],
355
+ signature_class: signature_class_name,
321
356
  metadata: saved_program.metadata
322
357
  }
323
358