dspy 0.27.5 → 0.28.0

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: d8a0e5d292482730715265985f5334aa60853c2146c9dde2dc18ffa9b882ca6d
4
- data.tar.gz: 9ae0827c01495b38b9250714254d841948a1191b74e3f7af59360f1b2e4705c1
3
+ metadata.gz: df0e7cf901df85567e1553a3d7df8659a71f254fa5671803ea98f0573de0c39a
4
+ data.tar.gz: b2d5d37cb05678f97143a1463410d92848ba10178bd50d60ee384827e9efe9b1
5
5
  SHA512:
6
- metadata.gz: b7912c2a40ea8f036211bf7553cac71746cfbf0b24790cb3a69df0bf513771fbcdf7d317d5e492ea8354b46acca0b17d62bd02a3a2debaf191343ff7934df68c
7
- data.tar.gz: cafc3cd07f94829e3aae3277630c2a875b6c2cfb982ca64fa493531b6274fe10079fca447824b42d31d8d1a7a4bb43714e5256d3ce7505e31b01a5d4b10007df
6
+ metadata.gz: 0d870f2d338fcdce0540143decd3674e94a9c24e5fad796536772e6c8064af75dcafc36533bf39a0a1eeefac30777de1d6541a3ab57eb2568a007260d7a5d7a3
7
+ data.tar.gz: 13f92278a81ca870f90b662fa40972e41aa6203d27a8792d3d03c13b97d971d7684a0f661dfb86bf03ef5c58e4a8f2f57c2ac48414ed9b8f8e5f0bce7628f231
data/README.md CHANGED
@@ -59,22 +59,41 @@ puts result.sentiment # => #<Sentiment::Positive>
59
59
  puts result.confidence # => 0.85
60
60
  ```
61
61
 
62
- ### Alternative Providers
62
+ ### Access to 200+ Models Across 5 Providers
63
63
 
64
- DSPy.rb supports multiple providers with native structured outputs:
64
+ DSPy.rb provides unified access to major LLM providers with provider-specific optimizations:
65
65
 
66
66
  ```ruby
67
- # Google Gemini with native structured outputs
67
+ # OpenAI (GPT-4, GPT-4o, GPT-4o-mini, GPT-5, etc.)
68
68
  DSPy.configure do |c|
69
- c.lm = DSPy::LM.new('gemini/gemini-1.5-flash',
70
- api_key: ENV['GEMINI_API_KEY'],
71
- structured_outputs: true) # Supports gemini-1.5-pro, gemini-1.5-flash, gemini-2.0-flash-exp
69
+ c.lm = DSPy::LM.new('openai/gpt-4o-mini',
70
+ api_key: ENV['OPENAI_API_KEY'],
71
+ structured_outputs: true) # Native JSON mode
72
+ end
73
+
74
+ # Google Gemini (Gemini 1.5 Pro, Flash, Gemini 2.0, etc.)
75
+ DSPy.configure do |c|
76
+ c.lm = DSPy::LM.new('gemini/gemini-2.5-flash',
77
+ api_key: ENV['GEMINI_API_KEY'],
78
+ structured_outputs: true) # Native structured outputs
79
+ end
80
+
81
+ # Anthropic Claude (Claude 3.5, Claude 4, etc.)
82
+ DSPy.configure do |c|
83
+ c.lm = DSPy::LM.new('anthropic/claude-sonnet-4-5-20250929',
84
+ api_key: ENV['ANTHROPIC_API_KEY'],
85
+ structured_outputs: true) # Tool-based extraction (default)
86
+ end
87
+
88
+ # Ollama - Run any local model (Llama, Mistral, Gemma, etc.)
89
+ DSPy.configure do |c|
90
+ c.lm = DSPy::LM.new('ollama/llama3.2') # Free, runs locally, no API key needed
72
91
  end
73
92
 
74
- # Anthropic Claude with tool-based extraction
93
+ # OpenRouter - Access to 200+ models from multiple providers
75
94
  DSPy.configure do |c|
76
- c.lm = DSPy::LM.new('anthropic/claude-3-sonnet-20241022',
77
- api_key: ENV['ANTHROPIC_API_KEY']) # Automatic strategy selection
95
+ c.lm = DSPy::LM.new('openrouter/deepseek/deepseek-chat-v3.1:free',
96
+ api_key: ENV['OPENROUTER_API_KEY'])
78
97
  end
79
98
  ```
80
99
 
@@ -13,7 +13,7 @@ module DSPy
13
13
  'openrouter' => 'OpenrouterAdapter'
14
14
  }.freeze
15
15
 
16
- PROVIDERS_WITH_EXTRA_OPTIONS = %w[openai ollama gemini openrouter].freeze
16
+ PROVIDERS_WITH_EXTRA_OPTIONS = %w[openai anthropic ollama gemini openrouter].freeze
17
17
 
18
18
  class << self
19
19
  # Creates an adapter instance based on model_id
@@ -6,10 +6,11 @@ require_relative '../vision_models'
6
6
  module DSPy
7
7
  class LM
8
8
  class AnthropicAdapter < Adapter
9
- def initialize(model:, api_key:)
10
- super
9
+ def initialize(model:, api_key:, structured_outputs: true)
10
+ super(model: model, api_key: api_key)
11
11
  validate_api_key!(api_key, 'anthropic')
12
12
  @client = Anthropic::Client.new(api_key: api_key)
13
+ @structured_outputs_enabled = structured_outputs
13
14
  end
14
15
 
15
16
  def chat(messages:, signature: nil, **extra_params, &block)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sorbet-runtime"
4
+
5
+ module DSPy
6
+ class LM
7
+ # Simple chat strategy that passes messages through without JSON extraction
8
+ class ChatStrategy
9
+ extend T::Sig
10
+
11
+ sig { params(adapter: T.untyped).void }
12
+ def initialize(adapter)
13
+ @adapter = adapter
14
+ end
15
+
16
+ # No modifications to messages for simple chat
17
+ sig { params(messages: T::Array[T::Hash[Symbol, T.untyped]], request_params: T::Hash[Symbol, T.untyped]).void }
18
+ def prepare_request(messages, request_params)
19
+ # Pass through unchanged
20
+ end
21
+
22
+ # No JSON extraction for chat
23
+ sig { params(response: DSPy::LM::Response).returns(NilClass) }
24
+ def extract_json(response)
25
+ nil
26
+ end
27
+
28
+ sig { returns(String) }
29
+ def name
30
+ 'chat'
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :adapter
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sorbet-runtime"
4
+ require_relative "adapters/openai/schema_converter"
5
+ require_relative "adapters/gemini/schema_converter"
6
+
7
+ module DSPy
8
+ class LM
9
+ # JSON extraction strategy with provider-specific handling
10
+ class JSONStrategy
11
+ extend T::Sig
12
+
13
+ sig { params(adapter: T.untyped, signature_class: T.class_of(DSPy::Signature)).void }
14
+ def initialize(adapter, signature_class)
15
+ @adapter = adapter
16
+ @signature_class = signature_class
17
+ end
18
+
19
+ # Prepare request with provider-specific JSON extraction parameters
20
+ sig { params(messages: T::Array[T::Hash[Symbol, T.untyped]], request_params: T::Hash[Symbol, T.untyped]).void }
21
+ def prepare_request(messages, request_params)
22
+ adapter_class_name = adapter.class.name
23
+
24
+ if adapter_class_name.include?('OpenAIAdapter') || adapter_class_name.include?('OllamaAdapter')
25
+ prepare_openai_request(request_params)
26
+ elsif adapter_class_name.include?('AnthropicAdapter')
27
+ prepare_anthropic_request(messages, request_params)
28
+ elsif adapter_class_name.include?('GeminiAdapter')
29
+ prepare_gemini_request(request_params)
30
+ end
31
+ # Unknown provider - no special handling
32
+ end
33
+
34
+ # Extract JSON from response based on provider
35
+ sig { params(response: DSPy::LM::Response).returns(T.nilable(String)) }
36
+ def extract_json(response)
37
+ adapter_class_name = adapter.class.name
38
+
39
+ if adapter_class_name.include?('OpenAIAdapter') || adapter_class_name.include?('OllamaAdapter')
40
+ # OpenAI/Ollama: try to extract JSON from various formats
41
+ extract_json_from_content(response.content)
42
+ elsif adapter_class_name.include?('AnthropicAdapter')
43
+ # Anthropic: try tool use first if structured_outputs enabled, else use content extraction
44
+ structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
45
+ structured_outputs_enabled = true if structured_outputs_enabled.nil? # Default to true
46
+
47
+ if structured_outputs_enabled
48
+ extracted = extract_anthropic_tool_json(response)
49
+ extracted || extract_json_from_content(response.content)
50
+ else
51
+ # Skip tool extraction, use enhanced prompting extraction
52
+ extract_json_from_content(response.content)
53
+ end
54
+ elsif adapter_class_name.include?('GeminiAdapter')
55
+ # Gemini: try to extract JSON from various formats
56
+ extract_json_from_content(response.content)
57
+ else
58
+ # Unknown provider: try to extract JSON
59
+ extract_json_from_content(response.content)
60
+ end
61
+ end
62
+
63
+ sig { returns(String) }
64
+ def name
65
+ 'json'
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :adapter, :signature_class
71
+
72
+ # OpenAI/Ollama preparation
73
+ sig { params(request_params: T::Hash[Symbol, T.untyped]).void }
74
+ def prepare_openai_request(request_params)
75
+ # Check if structured outputs are supported
76
+ if adapter.instance_variable_get(:@structured_outputs_enabled) &&
77
+ DSPy::LM::Adapters::OpenAI::SchemaConverter.supports_structured_outputs?(adapter.model)
78
+ response_format = DSPy::LM::Adapters::OpenAI::SchemaConverter.to_openai_format(signature_class)
79
+ request_params[:response_format] = response_format
80
+ end
81
+ end
82
+
83
+ # Anthropic preparation
84
+ sig { params(messages: T::Array[T::Hash[Symbol, T.untyped]], request_params: T::Hash[Symbol, T.untyped]).void }
85
+ def prepare_anthropic_request(messages, request_params)
86
+ # Only use tool-based extraction if structured_outputs is enabled (default: true)
87
+ structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
88
+
89
+ # Default to true if not set (backward compatibility)
90
+ structured_outputs_enabled = true if structured_outputs_enabled.nil?
91
+
92
+ return unless structured_outputs_enabled
93
+
94
+ # Convert signature to tool schema
95
+ tool_schema = convert_to_anthropic_tool_schema
96
+
97
+ # Add tool definition
98
+ request_params[:tools] = [tool_schema]
99
+
100
+ # Force tool use
101
+ request_params[:tool_choice] = {
102
+ type: "tool",
103
+ name: "json_output"
104
+ }
105
+
106
+ # Update last user message
107
+ if messages.any? && messages.last[:role] == "user"
108
+ messages.last[:content] += "\n\nPlease use the json_output tool to provide your response."
109
+ end
110
+ end
111
+
112
+ # Gemini preparation
113
+ sig { params(request_params: T::Hash[Symbol, T.untyped]).void }
114
+ def prepare_gemini_request(request_params)
115
+ # Check if structured outputs are supported
116
+ if adapter.instance_variable_get(:@structured_outputs_enabled) &&
117
+ DSPy::LM::Adapters::Gemini::SchemaConverter.supports_structured_outputs?(adapter.model)
118
+ schema = DSPy::LM::Adapters::Gemini::SchemaConverter.to_gemini_format(signature_class)
119
+
120
+ request_params[:generation_config] = {
121
+ response_mime_type: "application/json",
122
+ response_json_schema: schema
123
+ }
124
+ end
125
+ end
126
+
127
+ # Convert signature to Anthropic tool schema
128
+ sig { returns(T::Hash[Symbol, T.untyped]) }
129
+ def convert_to_anthropic_tool_schema
130
+ output_fields = signature_class.output_field_descriptors
131
+
132
+ {
133
+ name: "json_output",
134
+ description: "Output the result in the required JSON format",
135
+ input_schema: {
136
+ type: "object",
137
+ properties: build_properties_from_fields(output_fields),
138
+ required: output_fields.keys.map(&:to_s)
139
+ }
140
+ }
141
+ end
142
+
143
+ # Build JSON schema properties from output fields
144
+ sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
145
+ def build_properties_from_fields(fields)
146
+ properties = {}
147
+ fields.each do |field_name, descriptor|
148
+ properties[field_name.to_s] = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(descriptor.type)
149
+ end
150
+ properties
151
+ end
152
+
153
+ # Extract JSON from Anthropic tool use response
154
+ sig { params(response: DSPy::LM::Response).returns(T.nilable(String)) }
155
+ def extract_anthropic_tool_json(response)
156
+ # Check for tool calls in metadata
157
+ if response.metadata.respond_to?(:tool_calls) && response.metadata.tool_calls
158
+ tool_calls = response.metadata.tool_calls
159
+ if tool_calls.is_a?(Array) && !tool_calls.empty?
160
+ first_call = tool_calls.first
161
+ if first_call[:name] == "json_output" && first_call[:input]
162
+ return JSON.generate(first_call[:input])
163
+ end
164
+ end
165
+ end
166
+
167
+ nil
168
+ end
169
+
170
+ # Extract JSON from content that may contain markdown or plain JSON
171
+ sig { params(content: String).returns(String) }
172
+ def extract_json_from_content(content)
173
+ return content if content.nil? || content.empty?
174
+
175
+ # Try 1: Check for ```json code block (with or without preceding text)
176
+ if content.include?('```json')
177
+ json_match = content.match(/```json\s*\n(.*?)\n```/m)
178
+ return json_match[1].strip if json_match
179
+ end
180
+
181
+ # Try 2: Check for generic ``` code block
182
+ if content.include?('```')
183
+ code_match = content.match(/```\s*\n(.*?)\n```/m)
184
+ if code_match
185
+ potential_json = code_match[1].strip
186
+ # Verify it's JSON
187
+ begin
188
+ JSON.parse(potential_json)
189
+ return potential_json
190
+ rescue JSON::ParserError
191
+ # Not valid JSON, continue
192
+ end
193
+ end
194
+ end
195
+
196
+ # Try 3: Try parsing entire content as JSON
197
+ begin
198
+ JSON.parse(content)
199
+ return content
200
+ rescue JSON::ParserError
201
+ # Not pure JSON, try extracting
202
+ end
203
+
204
+ # Try 4: Look for JSON object pattern in text (greedy match for nested objects)
205
+ json_pattern = /\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}/m
206
+ json_match = content.match(json_pattern)
207
+ if json_match
208
+ potential_json = json_match[0]
209
+ begin
210
+ JSON.parse(potential_json)
211
+ return potential_json
212
+ rescue JSON::ParserError
213
+ # Not valid JSON
214
+ end
215
+ end
216
+
217
+ # Return content as-is if no JSON found
218
+ content
219
+ end
220
+ end
221
+ end
222
+ end
data/lib/dspy/lm.rb CHANGED
@@ -20,8 +20,8 @@ require_relative 'lm/adapters/gemini_adapter'
20
20
  require_relative 'lm/adapters/openrouter_adapter'
21
21
 
22
22
  # Load strategy system
23
- require_relative 'lm/strategy_selector'
24
- require_relative 'lm/retry_handler'
23
+ require_relative 'lm/chat_strategy'
24
+ require_relative 'lm/json_strategy'
25
25
 
26
26
  # Load message builder and message types
27
27
  require_relative 'lm/message'
@@ -64,7 +64,10 @@ module DSPy
64
64
  response = instrument_lm_request(messages, signature_class.name) do
65
65
  chat_with_strategy(messages, signature_class, &block)
66
66
  end
67
-
67
+
68
+ # Emit the standard lm.tokens event (consistent with raw_chat)
69
+ emit_token_usage(response, signature_class.name)
70
+
68
71
  # Parse response (no longer needs separate instrumentation)
69
72
  parsed_result = parse_response(response, input_values, signature_class)
70
73
 
@@ -96,21 +99,15 @@ module DSPy
96
99
  private
97
100
 
98
101
  def chat_with_strategy(messages, signature_class, &block)
99
- # Select the best strategy for JSON extraction
100
- strategy_selector = StrategySelector.new(adapter, signature_class)
101
- initial_strategy = strategy_selector.select
102
-
103
- if DSPy.config.structured_outputs.retry_enabled && signature_class
104
- # Use retry handler for JSON responses
105
- retry_handler = RetryHandler.new(adapter, signature_class)
106
-
107
- retry_handler.with_retry(initial_strategy) do |strategy|
108
- execute_chat_with_strategy(messages, signature_class, strategy, &block)
109
- end
102
+ # Choose strategy based on whether we need JSON extraction
103
+ strategy = if signature_class
104
+ JSONStrategy.new(adapter, signature_class)
110
105
  else
111
- # No retry logic, just execute once
112
- execute_chat_with_strategy(messages, signature_class, initial_strategy, &block)
106
+ ChatStrategy.new(adapter)
113
107
  end
108
+
109
+ # Execute with the selected strategy (no retry, no fallback)
110
+ execute_chat_with_strategy(messages, signature_class, strategy, &block)
114
111
  end
115
112
 
116
113
  def execute_chat_with_strategy(messages, signature_class, strategy, &block)