dspy 0.27.2 → 0.27.4

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: 07ebaf2db9b6279275ac4dd61ac4f994ed5415561b0063310f006db084306092
4
- data.tar.gz: 8e8c1cbfcb36d9ffa4bfd68f62317fba7d5d32a6b1409ce43749f555a719fc97
3
+ metadata.gz: dca273acbd2ba8ab0ab9e2980bdf8048cc9ea80b3ce1b74d6f1e911e165ae895
4
+ data.tar.gz: '08177e7841275140c7f074ac3a700883f06c6610c9bfd4795d142a3c838c0b24'
5
5
  SHA512:
6
- metadata.gz: 8f19c7e01b5e03743457c9784b8cca05ef42e8002ff210dd59f08915f11ceb7c1c000f8b0eac6cd102c847f069194923a46c5197473ea53cda7616921ea1da26
7
- data.tar.gz: 9ed8e5ec4e08eb83f17dbea912a7a14b28212968f36dc7adf6cad7dd7e62de8506e371e99bb4f8f1d785847b45783fdfada53dae3d1d039a4f8695aba54984b4
6
+ metadata.gz: 1b8d4bd756ab303688366fb0bcb5e5a3e045152c5695a8cdcd5291ca6313fc8ee7bd8051c6026663cfda0ec429b7977aa2355442595ba803bfedafd3926e89b6
7
+ data.tar.gz: 30efa43a8bc34708003a178d7715ae6688268b4b63e98789f011e47f7d36b8c30f292ac986c8df3767bafa517b7b73f5ff7fa92fec8013732c69952dfa79df71
@@ -9,9 +9,12 @@ module DSPy
9
9
  'openai' => 'OpenAIAdapter',
10
10
  'anthropic' => 'AnthropicAdapter',
11
11
  'ollama' => 'OllamaAdapter',
12
- 'gemini' => 'GeminiAdapter'
12
+ 'gemini' => 'GeminiAdapter',
13
+ 'openrouter' => 'OpenrouterAdapter'
13
14
  }.freeze
14
15
 
16
+ PROVIDERS_WITH_EXTRA_OPTIONS = %w[openai ollama gemini openrouter].freeze
17
+
15
18
  class << self
16
19
  # Creates an adapter instance based on model_id
17
20
  # @param model_id [String] Full model identifier (e.g., "openai/gpt-4")
@@ -24,8 +27,8 @@ module DSPy
24
27
 
25
28
  # Pass provider-specific options
26
29
  adapter_options = { model: model, api_key: api_key }
27
- # OpenAI, Ollama, and Gemini accept additional options
28
- adapter_options.merge!(options) if %w[openai ollama gemini].include?(provider)
30
+ # Some providers accept additional options
31
+ adapter_options.merge!(options) if PROVIDERS_WITH_EXTRA_OPTIONS.include?(provider)
29
32
 
30
33
  adapter_class.new(**adapter_options)
31
34
  end
@@ -11,24 +11,29 @@ module DSPy
11
11
  extend T::Sig
12
12
 
13
13
  # Models that support structured outputs (JSON + Schema)
14
- # Based on official Google documentation and gemini-ai gem table
14
+ # Based on official Google documentation (Sept 2025)
15
15
  STRUCTURED_OUTPUT_MODELS = T.let([
16
- "gemini-1.5-pro", # ✅ Full schema support (legacy)
17
- "gemini-1.5-pro-preview-0514", # ✅ Full schema support (legacy)
18
- "gemini-1.5-pro-preview-0409", # ✅ Full schema support (legacy)
19
- "gemini-2.5-flash", # ✅ Full schema support (2025 current)
20
- "gemini-2.5-flash-lite" # ✅ Full schema support (2025 current)
16
+ # Gemini 1.5 series
17
+ "gemini-1.5-pro",
18
+ "gemini-1.5-pro-preview-0514",
19
+ "gemini-1.5-pro-preview-0409",
20
+ "gemini-1.5-flash", # ✅ Now supports structured outputs
21
+ "gemini-1.5-flash-8b",
22
+ # Gemini 2.0 series
23
+ "gemini-2.0-flash",
24
+ "gemini-2.0-flash-001",
25
+ # Gemini 2.5 series
26
+ "gemini-2.5-pro",
27
+ "gemini-2.5-flash",
28
+ "gemini-2.5-flash-lite"
21
29
  ].freeze, T::Array[String])
22
30
 
23
- # Models that support JSON mode but NOT schema
24
- JSON_ONLY_MODELS = T.let([
25
- "gemini-pro", # 🟡 JSON only, no schema
26
- "gemini-1.5-flash", # 🟡 JSON only, no schema (legacy)
27
- "gemini-1.5-flash-preview-0514", # 🟡 JSON only, no schema (legacy)
28
- "gemini-1.0-pro-002", # 🟡 JSON only, no schema
29
- "gemini-1.0-pro", # 🟡 JSON only, no schema
30
- "gemini-2.0-flash-001", # 🟡 JSON only, no schema (2025)
31
- "gemini-2.0-flash-lite-001" # 🟡 JSON only, no schema (2025)
31
+ # Models that do not support structured outputs (legacy only)
32
+ UNSUPPORTED_MODELS = T.let([
33
+ # Legacy Gemini 1.0 series only
34
+ "gemini-pro",
35
+ "gemini-1.0-pro-002",
36
+ "gemini-1.0-pro"
32
37
  ].freeze, T::Array[String])
33
38
 
34
39
  sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T::Hash[Symbol, T.untyped]) }
@@ -14,11 +14,16 @@ module DSPy
14
14
  @structured_outputs_enabled = structured_outputs
15
15
 
16
16
  # Disable streaming for VCR tests since SSE responses don't record properly
17
+ # But keep streaming enabled for SSEVCR tests (SSE-specific cassettes)
17
18
  @use_streaming = true
18
19
  begin
19
- @use_streaming = false if defined?(VCR) && VCR.current_cassette
20
+ vcr_active = defined?(VCR) && VCR.current_cassette
21
+ ssevcr_active = defined?(SSEVCR) && SSEVCR.turned_on?
22
+
23
+ # Only disable streaming if regular VCR is active but SSEVCR is not
24
+ @use_streaming = false if vcr_active && !ssevcr_active
20
25
  rescue
21
- # If VCR is not available or any error occurs, use streaming
26
+ # If VCR/SSEVCR is not available or any error occurs, use streaming
22
27
  @use_streaming = true
23
28
  end
24
29
 
@@ -29,10 +29,9 @@ module DSPy
29
29
  normalized_messages = handle_o1_messages(normalized_messages)
30
30
  end
31
31
 
32
- request_params = {
33
- model: model,
32
+ request_params = default_request_params.merge(
34
33
  messages: normalized_messages
35
- }
34
+ )
36
35
 
37
36
  # Add temperature based on model capabilities
38
37
  unless o1_model?(model)
@@ -123,6 +122,15 @@ module DSPy
123
122
  end
124
123
  end
125
124
 
125
+ protected
126
+
127
+ # Allow subclasses to override request params (add headers, etc)
128
+ def default_request_params
129
+ {
130
+ model: model
131
+ }
132
+ end
133
+
126
134
  private
127
135
 
128
136
  def supports_structured_outputs?
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openai'
4
+
5
+ module DSPy
6
+ class LM
7
+ class OpenrouterAdapter < OpenAIAdapter
8
+ BASE_URL = 'https://openrouter.ai/api/v1'
9
+
10
+ def initialize(model:, api_key: nil, structured_outputs: true, http_referrer: nil, x_title: nil)
11
+ # Don't call parent's initialize, do it manually to control client creation
12
+ @model = model
13
+ @api_key = api_key
14
+ @structured_outputs_enabled = structured_outputs
15
+
16
+ @http_referrer = http_referrer
17
+ @x_title = x_title
18
+
19
+ validate_configuration!
20
+
21
+ # Create client with custom base URL
22
+ @client = OpenAI::Client.new(
23
+ api_key: @api_key,
24
+ base_url: BASE_URL
25
+ )
26
+ end
27
+
28
+ def chat(messages:, signature: nil, response_format: nil, &block)
29
+ # For OpenRouter, we need to be more lenient with structured outputs
30
+ # as the model behind it may not fully support OpenAI's response_format spec
31
+ begin
32
+ super
33
+ rescue => e
34
+ # If structured output fails, retry with enhanced prompting
35
+ if @structured_outputs_enabled && signature && e.message.include?('response_format')
36
+ DSPy.logger.debug("OpenRouter structured output failed, falling back to enhanced prompting")
37
+ @structured_outputs_enabled = false
38
+ retry
39
+ else
40
+ raise
41
+ end
42
+ end
43
+ end
44
+
45
+ protected
46
+
47
+ # Add any OpenRouter-specific headers to all requests
48
+ def default_request_params
49
+ headers = {
50
+ 'X-Title' => @x_title,
51
+ 'HTTP-Referer' => @http_referrer
52
+ }.compact
53
+
54
+ upstream_params = super
55
+ upstream_params.merge!(request_options: { extra_headers: headers }) if headers.any?
56
+ upstream_params
57
+ end
58
+
59
+ private
60
+
61
+ def supports_structured_outputs?
62
+ # Different models behind OpenRouter may have different capabilities
63
+ # For now, we rely on whatever was passed to the constructor
64
+ @structured_outputs_enabled
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/dspy/lm.rb CHANGED
@@ -17,6 +17,7 @@ require_relative 'lm/adapters/openai_adapter'
17
17
  require_relative 'lm/adapters/anthropic_adapter'
18
18
  require_relative 'lm/adapters/ollama_adapter'
19
19
  require_relative 'lm/adapters/gemini_adapter'
20
+ require_relative 'lm/adapters/openrouter_adapter'
20
21
 
21
22
  # Load strategy system
22
23
  require_relative 'lm/strategy_selector'
@@ -37,11 +37,17 @@ module DSPy
37
37
  when ->(type) { hash_type?(type) }
38
38
  coerce_hash_value(value, prop_type)
39
39
  when ->(type) { enum_type?(type) }
40
- extract_enum_class(prop_type).deserialize(value)
41
- when Float, ->(type) { simple_type_match?(type, Float) }
40
+ coerce_enum_value(value, prop_type)
41
+ when ->(type) { type == Float || simple_type_match?(type, Float) }
42
42
  value.to_f
43
- when Integer, ->(type) { simple_type_match?(type, Integer) }
43
+ when ->(type) { type == Integer || simple_type_match?(type, Integer) }
44
44
  value.to_i
45
+ when ->(type) { type == Date || simple_type_match?(type, Date) }
46
+ coerce_date_value(value)
47
+ when ->(type) { type == DateTime || simple_type_match?(type, DateTime) }
48
+ coerce_datetime_value(value)
49
+ when ->(type) { type == Time || simple_type_match?(type, Time) }
50
+ coerce_time_value(value)
45
51
  when ->(type) { struct_type?(type) }
46
52
  coerce_struct_value(value, prop_type)
47
53
  else
@@ -244,6 +250,63 @@ module DSPy
244
250
  DSPy.logger.debug("Failed to coerce union type: #{e.message}")
245
251
  value
246
252
  end
253
+
254
+ # Coerces a date value from string using ISO 8601 format
255
+ sig { params(value: T.untyped).returns(T.nilable(Date)) }
256
+ def coerce_date_value(value)
257
+ return value if value.is_a?(Date)
258
+ return nil if value.nil? || value.to_s.strip.empty?
259
+
260
+ # Support ISO 8601 format (YYYY-MM-DD) like ActiveRecord
261
+ Date.parse(value.to_s)
262
+ rescue ArgumentError, TypeError
263
+ # Return nil for invalid dates rather than crashing
264
+ DSPy.logger.debug("Failed to coerce to Date: #{value}")
265
+ nil
266
+ end
267
+
268
+ # Coerces a datetime value from string using ISO 8601 format with timezone
269
+ sig { params(value: T.untyped).returns(T.nilable(DateTime)) }
270
+ def coerce_datetime_value(value)
271
+ return value if value.is_a?(DateTime)
272
+ return nil if value.nil? || value.to_s.strip.empty?
273
+
274
+ # Parse ISO 8601 with timezone like ActiveRecord
275
+ # Formats: 2024-01-15T10:30:45Z, 2024-01-15T10:30:45+00:00, 2024-01-15 10:30:45
276
+ DateTime.parse(value.to_s)
277
+ rescue ArgumentError, TypeError
278
+ DSPy.logger.debug("Failed to coerce to DateTime: #{value}")
279
+ nil
280
+ end
281
+
282
+ # Coerces a time value from string, converting to UTC like ActiveRecord
283
+ sig { params(value: T.untyped).returns(T.nilable(Time)) }
284
+ def coerce_time_value(value)
285
+ return value if value.is_a?(Time)
286
+ return nil if value.nil? || value.to_s.strip.empty?
287
+
288
+ # Parse and convert to UTC (like ActiveRecord with time_zone_aware_attributes)
289
+ # This ensures consistent timezone handling across the system
290
+ Time.parse(value.to_s).utc
291
+ rescue ArgumentError, TypeError
292
+ DSPy.logger.debug("Failed to coerce to Time: #{value}")
293
+ nil
294
+ end
295
+
296
+ # Coerces a value to an enum, handling both strings and existing enum instances
297
+ sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
298
+ def coerce_enum_value(value, prop_type)
299
+ enum_class = extract_enum_class(prop_type)
300
+
301
+ # If value is already an instance of the enum class, return it as-is
302
+ return value if value.is_a?(enum_class)
303
+
304
+ # Otherwise, try to deserialize from string
305
+ enum_class.deserialize(value.to_s)
306
+ rescue ArgumentError, KeyError => e
307
+ DSPy.logger.debug("Failed to coerce to enum #{enum_class}: #{e.message}")
308
+ value
309
+ end
247
310
  end
248
311
  end
249
312
  end
@@ -107,6 +107,7 @@ module DSPy
107
107
 
108
108
  def start_export_task
109
109
  return if @export_interval <= 0 # Disable timer for testing
110
+ return if ENV['DSPY_DISABLE_OBSERVABILITY'] == 'true' # Skip in tests
110
111
 
111
112
  # Start timer-based export task in background
112
113
  Thread.new do
@@ -129,6 +130,7 @@ module DSPy
129
130
 
130
131
  def trigger_export_if_batch_full
131
132
  return if @queue.size < @export_batch_size
133
+ return if ENV['DSPY_DISABLE_OBSERVABILITY'] == 'true' # Skip in tests
132
134
 
133
135
  # Trigger immediate export in background
134
136
  Thread.new do
@@ -11,6 +11,12 @@ module DSPy
11
11
  def configure!
12
12
  @enabled = false
13
13
 
14
+ # Check for explicit disable flag first
15
+ if ENV['DSPY_DISABLE_OBSERVABILITY'] == 'true'
16
+ DSPy.log('observability.disabled', reason: 'Explicitly disabled via DSPY_DISABLE_OBSERVABILITY')
17
+ return
18
+ end
19
+
14
20
  # Check for required Langfuse environment variables
15
21
  public_key = ENV['LANGFUSE_PUBLIC_KEY']
16
22
  secret_key = ENV['LANGFUSE_SECRET_KEY']
@@ -130,6 +136,17 @@ module DSPy
130
136
 
131
137
  def reset!
132
138
  @enabled = false
139
+
140
+ # Shutdown OpenTelemetry if it's configured
141
+ if defined?(OpenTelemetry) && OpenTelemetry.tracer_provider
142
+ begin
143
+ OpenTelemetry.tracer_provider.shutdown(timeout: 1.0)
144
+ rescue => e
145
+ # Ignore shutdown errors in tests - log them but don't fail
146
+ DSPy.log('observability.shutdown_error', error: e.message) if respond_to?(:log)
147
+ end
148
+ end
149
+
133
150
  @tracer = nil
134
151
  @endpoint = nil
135
152
  end
data/lib/dspy/predict.rb CHANGED
@@ -137,11 +137,15 @@ module DSPy
137
137
  def forward_untyped(**input_values)
138
138
  # Module#forward handles span creation, we just do the prediction logic
139
139
 
140
- # Store input values for optimization
141
- @last_input_values = input_values.clone
140
+ # Apply type coercion to input values first
141
+ input_props = @signature_class.input_struct_class.props
142
+ coerced_input_values = coerce_output_attributes(input_values, input_props)
143
+
144
+ # Store coerced input values for optimization
145
+ @last_input_values = coerced_input_values.clone
142
146
 
143
- # Validate input
144
- validate_input_struct(input_values)
147
+ # Validate input with coerced values
148
+ validate_input_struct(coerced_input_values)
145
149
 
146
150
  # Check if LM is configured
147
151
  current_lm = lm
@@ -149,19 +153,19 @@ module DSPy
149
153
  raise DSPy::ConfigurationError.missing_lm(self.class.name)
150
154
  end
151
155
 
152
- # Call LM and process response
153
- output_attributes = current_lm.chat(self, input_values)
156
+ # Call LM and process response with coerced input values
157
+ output_attributes = current_lm.chat(self, coerced_input_values)
154
158
  processed_output = process_lm_output(output_attributes)
155
159
 
156
- # Create combined result struct
157
- prediction_result = create_prediction_result(input_values, processed_output)
160
+ # Create combined result struct with coerced input values
161
+ prediction_result = create_prediction_result(coerced_input_values, processed_output)
158
162
 
159
163
  prediction_result
160
164
  end
161
165
 
162
166
  private
163
167
 
164
- # Validates input using signature struct
168
+ # Validates input using signature struct (assumes input is already coerced)
165
169
  sig { params(input_values: T::Hash[Symbol, T.untyped]).void }
166
170
  def validate_input_struct(input_values)
167
171
  @signature_class.input_struct_class.new(**input_values)
data/lib/dspy/re_act.rb CHANGED
@@ -66,6 +66,13 @@ module DSPy
66
66
  extend T::Sig
67
67
  include Mixins::StructBuilder
68
68
 
69
+ # AvailableTool struct for better type safety in ReAct agents
70
+ class AvailableTool < T::Struct
71
+ const :name, String
72
+ const :description, String
73
+ const :schema, T::Hash[Symbol, T.untyped]
74
+ end
75
+
69
76
  FINISH_ACTION = "finish"
70
77
  sig { returns(T.class_of(DSPy::Signature)) }
71
78
  attr_reader :original_signature_class
@@ -87,6 +94,9 @@ module DSPy
87
94
  tools.each { |tool| @tools[tool.name.downcase] = tool }
88
95
  @max_iterations = max_iterations
89
96
 
97
+ # Create dynamic ActionEnum class with tool names + finish
98
+ @action_enum_class = create_action_enum_class
99
+
90
100
  # Create dynamic signature classes that include the original input fields
91
101
  thought_signature = create_thought_signature(signature_class)
92
102
  observation_signature = create_observation_signature(signature_class)
@@ -143,9 +153,34 @@ module DSPy
143
153
 
144
154
  private
145
155
 
156
+ # Creates a dynamic ActionEnum class with tool names and "finish"
157
+ sig { returns(T.class_of(T::Enum)) }
158
+ def create_action_enum_class
159
+ tool_names = @tools.keys
160
+ all_actions = tool_names + [FINISH_ACTION]
161
+
162
+ # Create a dynamic enum class using proper T::Enum pattern
163
+ enum_class = Class.new(T::Enum)
164
+
165
+ # Build the enums block code dynamically
166
+ enum_definitions = all_actions.map do |action_name|
167
+ const_name = action_name.upcase.gsub(/[^A-Z0-9_]/, '_')
168
+ "#{const_name} = new(#{action_name.inspect})"
169
+ end.join("\n ")
170
+
171
+ enum_class.class_eval <<~RUBY
172
+ enums do
173
+ #{enum_definitions}
174
+ end
175
+ RUBY
176
+
177
+ enum_class
178
+ end
179
+
146
180
  # Creates a dynamic Thought signature that includes the original input fields
147
181
  sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T.class_of(DSPy::Signature)) }
148
182
  def create_thought_signature(signature_class)
183
+ action_enum_class = @action_enum_class
149
184
  # Create new class that inherits from DSPy::Signature
150
185
  Class.new(DSPy::Signature) do
151
186
  # Set description
@@ -154,21 +189,21 @@ module DSPy
154
189
  # Define input fields
155
190
  input do
156
191
  const :input_context, String,
157
- desc: "Serialized representation of all input fields"
192
+ description: "Serialized representation of all input fields"
158
193
  const :history, T::Array[HistoryEntry],
159
- desc: "Previous thoughts and actions, including observations from tools."
160
- const :available_tools, T::Array[T::Hash[String, T.untyped]],
161
- desc: "Array of available tools with their JSON schemas."
194
+ description: "Previous thoughts and actions, including observations from tools."
195
+ const :available_tools, T::Array[AvailableTool],
196
+ description: "Array of available tools with their JSON schemas."
162
197
  end
163
198
 
164
199
  # Define output fields (same as ThoughtBase)
165
200
  output do
166
201
  const :thought, String,
167
- desc: "Reasoning about what to do next, considering the history and observations."
168
- const :action, String,
169
- desc: "The action to take. MUST be one of the tool names listed in `available_tools` input, or the literal string \"finish\" to provide the final answer."
202
+ description: "Reasoning about what to do next, considering the history and observations."
203
+ const :action, action_enum_class,
204
+ description: "The action to take. MUST be one of the tool names listed in `available_tools` input, or the literal string \"finish\" to provide the final answer."
170
205
  const :action_input, T.any(String, T::Hash[T.untyped, T.untyped]),
171
- desc: "Input for the chosen action. If action is a tool name, this MUST be a JSON object matching the tool's schema. If action is \"finish\", this field MUST contain the final result based on processing the input data."
206
+ description: "Input for the chosen action. If action is a tool name, this MUST be a JSON object matching the tool's schema. If action is \"finish\", this field MUST contain the final result based on processing the input data."
172
207
  end
173
208
  end
174
209
  end
@@ -184,19 +219,19 @@ module DSPy
184
219
  # Define input fields
185
220
  input do
186
221
  const :input_context, String,
187
- desc: "Serialized representation of all input fields"
222
+ description: "Serialized representation of all input fields"
188
223
  const :history, T::Array[HistoryEntry],
189
- desc: "Previous thoughts, actions, and observations."
224
+ description: "Previous thoughts, actions, and observations."
190
225
  const :observation, String,
191
- desc: "The result from the last action"
226
+ description: "The result from the last action"
192
227
  end
193
228
 
194
229
  # Define output fields (same as ReActObservationBase)
195
230
  output do
196
231
  const :interpretation, String,
197
- desc: "Interpretation of the observation"
232
+ description: "Interpretation of the observation"
198
233
  const :next_step, NextStep,
199
- desc: "What to do next: '#{NextStep::Continue}' or '#{NextStep::Finish}'"
234
+ description: "What to do next: '#{NextStep::Continue}' or '#{NextStep::Finish}'"
200
235
  end
201
236
  end
202
237
  end
@@ -205,7 +240,14 @@ module DSPy
205
240
  sig { params(input_struct: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
206
241
  def execute_react_reasoning_loop(input_struct)
207
242
  history = T.let([], T::Array[HistoryEntry])
208
- available_tools_desc = @tools.map { |name, tool| JSON.parse(tool.schema) }
243
+ available_tools_desc = @tools.map { |name, tool|
244
+ schema = JSON.parse(tool.schema)
245
+ AvailableTool.new(
246
+ name: name,
247
+ description: tool.description,
248
+ schema: schema.transform_keys(&:to_sym)
249
+ )
250
+ }
209
251
  final_answer = T.let(nil, T.nilable(String))
210
252
  iterations_count = 0
211
253
  last_observation = T.let(nil, T.nilable(String))
@@ -239,7 +281,7 @@ module DSPy
239
281
  end
240
282
 
241
283
  # Executes a single iteration of the ReAct loop
242
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[T::Hash[String, T.untyped]], iteration: Integer, tools_used: T::Array[String], last_observation: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
284
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[AvailableTool], iteration: Integer, tools_used: T::Array[String], last_observation: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
243
285
  def execute_single_iteration(input_struct, history, available_tools_desc, iteration, tools_used, last_observation)
244
286
  # Track each iteration with agent span
245
287
  DSPy::Context.with_span(
@@ -272,12 +314,15 @@ module DSPy
272
314
  thought_obj.action, thought_obj.action_input, iteration
273
315
  )
274
316
 
317
+ # Convert action enum to string for processing and storage
318
+ action_str = thought_obj.action.respond_to?(:serialize) ? thought_obj.action.serialize : thought_obj.action.to_s
319
+
275
320
  # Track tools used
276
- tools_used << thought_obj.action.downcase if valid_tool?(thought_obj.action)
321
+ tools_used << action_str.downcase if valid_tool?(thought_obj.action)
277
322
 
278
323
  # Add to history
279
324
  history << create_history_entry(
280
- iteration, thought_obj.thought, thought_obj.action,
325
+ iteration, thought_obj.thought, action_str,
281
326
  thought_obj.action_input, observation
282
327
  )
283
328
 
@@ -291,7 +336,7 @@ module DSPy
291
336
  end
292
337
 
293
338
  emit_iteration_complete_event(
294
- iteration, thought_obj.thought, thought_obj.action,
339
+ iteration, thought_obj.thought, action_str,
295
340
  thought_obj.action_input, observation, tools_used
296
341
  )
297
342
 
@@ -341,31 +386,39 @@ module DSPy
341
386
  final_answer.nil? && (@max_iterations.nil? || iterations_count < @max_iterations)
342
387
  end
343
388
 
344
- sig { params(action: T.nilable(String)).returns(T::Boolean) }
389
+ sig { params(action: T.nilable(T.any(String, T::Enum))).returns(T::Boolean) }
345
390
  def finish_action?(action)
346
- action&.downcase == FINISH_ACTION
391
+ return false unless action
392
+ action_str = action.respond_to?(:serialize) ? action.serialize : action.to_s
393
+ action_str.downcase == FINISH_ACTION
347
394
  end
348
395
 
349
- sig { params(action: T.nilable(String)).returns(T::Boolean) }
396
+ sig { params(action: T.nilable(T.any(String, T::Enum))).returns(T::Boolean) }
350
397
  def valid_tool?(action)
351
- !!(action && @tools[action.downcase])
398
+ return false unless action
399
+ action_str = action.respond_to?(:serialize) ? action.serialize : action.to_s
400
+ !!@tools[action_str.downcase]
352
401
  end
353
402
 
354
- sig { params(action: T.nilable(String), action_input: T.untyped, iteration: Integer).returns(String) }
403
+ sig { params(action: T.nilable(T.any(String, T::Enum)), action_input: T.untyped, iteration: Integer).returns(String) }
355
404
  def execute_tool_with_instrumentation(action, action_input, iteration)
356
- if action && @tools[action.downcase]
405
+ return "Unknown action: #{action}. Available actions: #{@tools.keys.join(', ')}, finish" unless action
406
+
407
+ action_str = action.respond_to?(:serialize) ? action.serialize : action.to_s
408
+
409
+ if @tools[action_str.downcase]
357
410
  DSPy::Context.with_span(
358
411
  operation: 'react.tool_call',
359
412
  **DSPy::ObservationType::Tool.langfuse_attributes,
360
413
  'dspy.module' => 'ReAct',
361
414
  'react.iteration' => iteration,
362
- 'tool.name' => action.downcase,
415
+ 'tool.name' => action_str.downcase,
363
416
  'tool.input' => action_input
364
417
  ) do
365
- execute_action(action, action_input)
418
+ execute_action(action_str, action_input)
366
419
  end
367
420
  else
368
- "Unknown action: #{action}. Available actions: #{@tools.keys.join(', ')}, finish"
421
+ "Unknown action: #{action_str}. Available actions: #{@tools.keys.join(', ')}, finish"
369
422
  end
370
423
  end
371
424
 
@@ -380,7 +433,7 @@ module DSPy
380
433
  )
381
434
  end
382
435
 
383
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], observation: String, available_tools_desc: T::Array[T::Hash[String, T.untyped]], iteration: Integer).returns(T::Hash[Symbol, T.untyped]) }
436
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], observation: String, available_tools_desc: T::Array[AvailableTool], iteration: Integer).returns(T::Hash[Symbol, T.untyped]) }
384
437
  def process_observation_and_decide_next_step(input_struct, history, observation, available_tools_desc, iteration)
385
438
  return { should_finish: false } if observation.include?("Unknown action")
386
439
 
@@ -399,7 +452,7 @@ module DSPy
399
452
  { should_finish: true, final_answer: final_answer }
400
453
  end
401
454
 
402
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[T::Hash[String, T.untyped]], observation_result: T.untyped, iteration: Integer).returns(String) }
455
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[AvailableTool], observation_result: T.untyped, iteration: Integer).returns(String) }
403
456
  def generate_forced_final_answer(input_struct, history, available_tools_desc, observation_result, iteration)
404
457
  final_thought = @thought_generator.forward(
405
458
  input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
@@ -407,7 +460,8 @@ module DSPy
407
460
  available_tools: available_tools_desc
408
461
  )
409
462
 
410
- if final_thought.action&.downcase != FINISH_ACTION
463
+ action_str = final_thought.action.respond_to?(:serialize) ? final_thought.action.serialize : final_thought.action.to_s
464
+ if action_str.downcase != FINISH_ACTION
411
465
  forced_answer = if observation_result.interpretation && !observation_result.interpretation.empty?
412
466
  observation_result.interpretation
413
467
  else
@@ -516,7 +570,7 @@ module DSPy
516
570
  example
517
571
  end
518
572
 
519
- sig { params(action_input: T.untyped, last_observation: T.nilable(String), step: Integer, thought: String, action: String, history: T::Array[HistoryEntry]).returns(String) }
573
+ sig { params(action_input: T.untyped, last_observation: T.nilable(String), step: Integer, thought: String, action: T.any(String, T::Enum), history: T::Array[HistoryEntry]).returns(String) }
520
574
  def handle_finish_action(action_input, last_observation, step, thought, action, history)
521
575
  final_answer = action_input.to_s
522
576
 
@@ -525,11 +579,14 @@ module DSPy
525
579
  final_answer = last_observation
526
580
  end
527
581
 
582
+ # Convert action enum to string for storage in history
583
+ action_str = action.respond_to?(:serialize) ? action.serialize : action.to_s
584
+
528
585
  # Always add the finish action to history
529
586
  history << HistoryEntry.new(
530
587
  step: step,
531
588
  thought: thought,
532
- action: action,
589
+ action: action_str,
533
590
  action_input: final_answer,
534
591
  observation: nil # No observation for finish action
535
592
  )
@@ -207,6 +207,12 @@ module DSPy
207
207
  { type: "number" }
208
208
  elsif type == Numeric
209
209
  { type: "number" }
210
+ elsif type == Date
211
+ { type: "string", format: "date" }
212
+ elsif type == DateTime
213
+ { type: "string", format: "date-time" }
214
+ elsif type == Time
215
+ { type: "string", format: "date-time" }
210
216
  elsif [TrueClass, FalseClass].include?(type)
211
217
  { type: "boolean" }
212
218
  elsif type < T::Struct
@@ -225,6 +231,12 @@ module DSPy
225
231
  { type: "number" }
226
232
  when "Numeric"
227
233
  { type: "number" }
234
+ when "Date"
235
+ { type: "string", format: "date" }
236
+ when "DateTime"
237
+ { type: "string", format: "date-time" }
238
+ when "Time"
239
+ { type: "string", format: "date-time" }
228
240
  when "TrueClass", "FalseClass"
229
241
  { type: "boolean" }
230
242
  when "T::Boolean"
@@ -61,14 +61,10 @@ module DSPy
61
61
  toolset_name "github"
62
62
 
63
63
  # Expose methods as tools with descriptions
64
- tool :create_issue, description: "Create a new GitHub issue"
65
- tool :create_pr, description: "Create a new GitHub pull request"
66
64
  tool :list_issues, description: "List GitHub issues with optional filters"
67
65
  tool :list_prs, description: "List GitHub pull requests with optional filters"
68
66
  tool :get_issue, description: "Get details of a specific GitHub issue"
69
67
  tool :get_pr, description: "Get details of a specific GitHub pull request"
70
- tool :comment_on_issue, description: "Add a comment to a GitHub issue"
71
- tool :review_pr, description: "Add a review to a GitHub pull request"
72
68
  tool :api_request, description: "Make an arbitrary GitHub API request"
73
69
 
74
70
  sig { void }
@@ -76,64 +72,7 @@ module DSPy
76
72
  # No persistent state needed
77
73
  end
78
74
 
79
- sig { params(
80
- title: String,
81
- body: String,
82
- labels: T::Array[String],
83
- assignees: T::Array[String],
84
- repo: T.nilable(String)
85
- ).returns(String) }
86
- def create_issue(title:, body:, labels: [], assignees: [], repo: nil)
87
- cmd = build_gh_command(['issue', 'create'])
88
- cmd << ['--title', shell_escape(title)]
89
- cmd << ['--body', shell_escape(body)]
90
-
91
- labels.each { |label| cmd << ['--label', shell_escape(label)] }
92
- assignees.each { |assignee| cmd << ['--assignee', shell_escape(assignee)] }
93
-
94
- if repo
95
- cmd << ['--repo', shell_escape(repo)]
96
- end
97
-
98
- result = execute_command(cmd.flatten.join(' '))
99
-
100
- if result[:success]
101
- "Issue created successfully: #{result[:output].strip}"
102
- else
103
- "Failed to create issue: #{result[:error]}"
104
- end
105
- rescue => e
106
- "Error creating issue: #{e.message}"
107
- end
108
75
 
109
- sig { params(
110
- title: String,
111
- body: String,
112
- base: String,
113
- head: String,
114
- repo: T.nilable(String)
115
- ).returns(String) }
116
- def create_pr(title:, body:, base:, head:, repo: nil)
117
- cmd = build_gh_command(['pr', 'create'])
118
- cmd << ['--title', shell_escape(title)]
119
- cmd << ['--body', shell_escape(body)]
120
- cmd << ['--base', shell_escape(base)]
121
- cmd << ['--head', shell_escape(head)]
122
-
123
- if repo
124
- cmd << ['--repo', shell_escape(repo)]
125
- end
126
-
127
- result = execute_command(cmd.flatten.join(' '))
128
-
129
- if result[:success]
130
- "Pull request created successfully: #{result[:output].strip}"
131
- else
132
- "Failed to create pull request: #{result[:error]}"
133
- end
134
- rescue => e
135
- "Error creating pull request: #{e.message}"
136
- end
137
76
 
138
77
  sig { params(
139
78
  state: IssueState,
@@ -241,58 +180,7 @@ module DSPy
241
180
  "Error getting pull request: #{e.message}"
242
181
  end
243
182
 
244
- sig { params(
245
- issue_number: Integer,
246
- comment: String,
247
- repo: T.nilable(String)
248
- ).returns(String) }
249
- def comment_on_issue(issue_number:, comment:, repo: nil)
250
- cmd = build_gh_command(['issue', 'comment', issue_number.to_s])
251
- cmd << ['--body', shell_escape(comment)]
252
-
253
- if repo
254
- cmd << ['--repo', shell_escape(repo)]
255
- end
256
183
 
257
- result = execute_command(cmd.flatten.join(' '))
258
-
259
- if result[:success]
260
- "Comment added successfully to issue ##{issue_number}"
261
- else
262
- "Failed to add comment: #{result[:error]}"
263
- end
264
- rescue => e
265
- "Error adding comment: #{e.message}"
266
- end
267
-
268
- sig { params(
269
- pr_number: Integer,
270
- review_type: ReviewState,
271
- comment: T.nilable(String),
272
- repo: T.nilable(String)
273
- ).returns(String) }
274
- def review_pr(pr_number:, review_type:, comment: nil, repo: nil)
275
- cmd = build_gh_command(['pr', 'review', pr_number.to_s])
276
- cmd << ['--' + review_type.serialize.tr('_', '-')]
277
-
278
- if comment
279
- cmd << ['--body', shell_escape(comment)]
280
- end
281
-
282
- if repo
283
- cmd << ['--repo', shell_escape(repo)]
284
- end
285
-
286
- result = execute_command(cmd.flatten.join(' '))
287
-
288
- if result[:success]
289
- "Review added successfully to PR ##{pr_number}"
290
- else
291
- "Failed to add review: #{result[:error]}"
292
- end
293
- rescue => e
294
- "Error adding review: #{e.message}"
295
- end
296
184
 
297
185
  sig { params(
298
186
  endpoint: String,
@@ -301,6 +189,11 @@ module DSPy
301
189
  repo: T.nilable(String)
302
190
  ).returns(String) }
303
191
  def api_request(endpoint:, method: 'GET', fields: {}, repo: nil)
192
+ # Restrict to read-only operations
193
+ unless method.upcase == 'GET'
194
+ return "Error: Only GET requests are allowed for read-only access"
195
+ end
196
+
304
197
  cmd = build_gh_command(['api', endpoint])
305
198
  cmd << ['--method', method.upcase]
306
199
 
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.27.2"
4
+ VERSION = "0.27.4"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.2
4
+ version: 0.27.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-09-16 00:00:00.000000000 Z
10
+ date: 2025-09-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dry-configurable
@@ -212,6 +212,7 @@ files:
212
212
  - lib/dspy/lm/adapters/ollama_adapter.rb
213
213
  - lib/dspy/lm/adapters/openai/schema_converter.rb
214
214
  - lib/dspy/lm/adapters/openai_adapter.rb
215
+ - lib/dspy/lm/adapters/openrouter_adapter.rb
215
216
  - lib/dspy/lm/errors.rb
216
217
  - lib/dspy/lm/message.rb
217
218
  - lib/dspy/lm/message_builder.rb