dspy 0.27.3 → 0.27.5

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: d697eb8eb574ca5c23914c1911f1d7a03ad7411aa83b19bedf2231cacc544460
4
- data.tar.gz: 3086cbaa86d01b0dd09512c9f5893f8a31b8d9988eed6782a967c24e1c12fb01
3
+ metadata.gz: d8a0e5d292482730715265985f5334aa60853c2146c9dde2dc18ffa9b882ca6d
4
+ data.tar.gz: 9ae0827c01495b38b9250714254d841948a1191b74e3f7af59360f1b2e4705c1
5
5
  SHA512:
6
- metadata.gz: eae9e4cba177e6cea359f1ffd55ebaa4203cc5a6594b86ab5fc2b9b9e8c54cf24838a8d18102ab96fc9a8f4b85827c3cb02ef0b75c3d077c6c70301abb52f48d
7
- data.tar.gz: ecc26be5f85df66e911a71d5a1fa878cc42c79c7d63e42b2d1440836859814837f4ba782db18584161598867a5aef5ebbfef056a2988b4208767b8e0c1999013
6
+ metadata.gz: b7912c2a40ea8f036211bf7553cac71746cfbf0b24790cb3a69df0bf513771fbcdf7d317d5e492ea8354b46acca0b17d62bd02a3a2debaf191343ff7934df68c
7
+ data.tar.gz: cafc3cd07f94829e3aae3277630c2a875b6c2cfb982ca64fa493531b6274fe10079fca447824b42d31d8d1a7a4bb43714e5256d3ce7505e31b01a5d4b10007df
@@ -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'
@@ -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/re_act.rb CHANGED
@@ -369,13 +369,22 @@ module DSPy
369
369
  sig { params(input_kwargs: T::Hash[Symbol, T.untyped], reasoning_result: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
370
370
  def create_enhanced_result(input_kwargs, reasoning_result)
371
371
  output_field_name = @original_signature_class.output_struct_class.props.keys.first
372
+ final_answer = reasoning_result[:final_answer]
372
373
 
373
374
  output_data = input_kwargs.merge({
374
375
  history: reasoning_result[:history].map(&:to_h),
375
376
  iterations: reasoning_result[:iterations],
376
377
  tools_used: reasoning_result[:tools_used]
377
378
  })
378
- output_data[output_field_name] = reasoning_result[:final_answer]
379
+
380
+ # Check if final_answer is a String but the expected type is NOT String
381
+ # This happens when max iterations is reached or the LLM generates an error message
382
+ output_field_type = @original_signature_class.output_struct_class.props[output_field_name][:type_object]
383
+ if final_answer.is_a?(String) && !string_compatible_type?(output_field_type)
384
+ output_data[output_field_name] = default_value_for_type(output_field_type)
385
+ else
386
+ output_data[output_field_name] = final_answer
387
+ end
379
388
 
380
389
  @enhanced_output_struct.new(**output_data)
381
390
  end
@@ -502,6 +511,56 @@ module DSPy
502
511
  "No answer reached within #{@max_iterations} iterations"
503
512
  end
504
513
 
514
+ # Checks if a type is String or compatible with String (e.g., T.any(String, ...) or T.nilable(String))
515
+ sig { params(type_object: T.untyped).returns(T::Boolean) }
516
+ def string_compatible_type?(type_object)
517
+ case type_object
518
+ when T::Types::Simple
519
+ type_object.raw_type == String
520
+ when T::Types::Union
521
+ # Check if any of the union types is String
522
+ type_object.types.any? { |t| t.is_a?(T::Types::Simple) && t.raw_type == String }
523
+ else
524
+ false
525
+ end
526
+ end
527
+
528
+ # Returns an appropriate default value for a given Sorbet type
529
+ # This is used when max iterations is reached without a successful completion
530
+ sig { params(type_object: T.untyped).returns(T.untyped) }
531
+ def default_value_for_type(type_object)
532
+ # Handle TypedArray (T::Array[...])
533
+ if type_object.is_a?(T::Types::TypedArray)
534
+ return []
535
+ end
536
+
537
+ # Handle TypedHash (T::Hash[...])
538
+ if type_object.is_a?(T::Types::TypedHash)
539
+ return {}
540
+ end
541
+
542
+ # Handle simple types
543
+ case type_object
544
+ when T::Types::Simple
545
+ raw_type = type_object.raw_type
546
+ case raw_type.to_s
547
+ when 'String' then ''
548
+ when 'Integer' then 0
549
+ when 'Float' then 0.0
550
+ when 'TrueClass', 'FalseClass' then false
551
+ else
552
+ # For T::Struct types, return nil as fallback
553
+ nil
554
+ end
555
+ when T::Types::Union
556
+ # For unions, return nil (assuming it's nilable) or first non-nil default
557
+ nil
558
+ else
559
+ # Default fallback for unknown types
560
+ nil
561
+ end
562
+ end
563
+
505
564
  # Tool execution method
506
565
  sig { params(action: String, action_input: T.untyped).returns(String) }
507
566
  def execute_action(action, action_input)
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.3"
4
+ VERSION = "0.27.5"
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.3
4
+ version: 0.27.5
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-20 00:00:00.000000000 Z
10
+ date: 2025-09-30 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