dspy 0.30.1 → 0.31.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.
@@ -1,291 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'anthropic'
4
- require_relative '../vision_models'
5
-
6
- module DSPy
7
- class LM
8
- class AnthropicAdapter < Adapter
9
- def initialize(model:, api_key:, structured_outputs: true)
10
- super(model: model, api_key: api_key)
11
- validate_api_key!(api_key, 'anthropic')
12
- @client = Anthropic::Client.new(api_key: api_key)
13
- @structured_outputs_enabled = structured_outputs
14
- end
15
-
16
- def chat(messages:, signature: nil, **extra_params, &block)
17
- normalized_messages = normalize_messages(messages)
18
-
19
- # Validate vision support if images are present
20
- if contains_images?(normalized_messages)
21
- VisionModels.validate_vision_support!('anthropic', model)
22
- # Convert messages to Anthropic format with proper image handling
23
- normalized_messages = format_multimodal_messages(normalized_messages)
24
- end
25
-
26
- # Anthropic requires system message to be separate from messages
27
- system_message, user_messages = extract_system_message(normalized_messages)
28
-
29
- # Check if this is a tool use request
30
- has_tools = extra_params.key?(:tools) && !extra_params[:tools].empty?
31
-
32
- # Apply JSON prefilling if needed for better Claude JSON compliance (but not for tool use)
33
- unless has_tools || contains_images?(normalized_messages)
34
- user_messages = prepare_messages_for_json(user_messages, system_message)
35
- end
36
-
37
- request_params = {
38
- model: model,
39
- messages: user_messages,
40
- max_tokens: 4096, # Required for Anthropic
41
- temperature: 0.0 # DSPy default for deterministic responses
42
- }.merge(extra_params)
43
-
44
- # Add system message if present
45
- request_params[:system] = system_message if system_message
46
-
47
- # Add streaming if block provided
48
- if block_given?
49
- request_params[:stream] = true
50
- end
51
-
52
- begin
53
- if block_given?
54
- content = ""
55
- @client.messages.stream(**request_params) do |chunk|
56
- if chunk.respond_to?(:delta) && chunk.delta.respond_to?(:text)
57
- chunk_text = chunk.delta.text
58
- content += chunk_text
59
- block.call(chunk)
60
- end
61
- end
62
-
63
- # Create typed metadata for streaming response
64
- metadata = ResponseMetadataFactory.create('anthropic', {
65
- model: model,
66
- streaming: true
67
- })
68
-
69
- Response.new(
70
- content: content,
71
- usage: nil, # Usage not available in streaming
72
- metadata: metadata
73
- )
74
- else
75
- response = @client.messages.create(**request_params)
76
-
77
- if response.respond_to?(:error) && response.error
78
- raise AdapterError, "Anthropic API error: #{response.error}"
79
- end
80
-
81
- # Handle both text content and tool use
82
- content = ""
83
- tool_calls = []
84
-
85
- if response.content.is_a?(Array)
86
- response.content.each do |content_block|
87
- case content_block.type.to_s
88
- when "text"
89
- content += content_block.text
90
- when "tool_use"
91
- tool_calls << {
92
- id: content_block.id,
93
- name: content_block.name,
94
- input: content_block.input
95
- }
96
- end
97
- end
98
- end
99
-
100
- usage = response.usage
101
-
102
- # Convert usage data to typed struct
103
- usage_struct = UsageFactory.create('anthropic', usage)
104
-
105
- metadata = {
106
- provider: 'anthropic',
107
- model: model,
108
- response_id: response.id,
109
- role: response.role
110
- }
111
-
112
- # Add tool calls to metadata if present
113
- metadata[:tool_calls] = tool_calls unless tool_calls.empty?
114
-
115
- # Create typed metadata
116
- typed_metadata = ResponseMetadataFactory.create('anthropic', metadata)
117
-
118
- Response.new(
119
- content: content,
120
- usage: usage_struct,
121
- metadata: typed_metadata
122
- )
123
- end
124
- rescue => e
125
- # Check for specific image-related errors in the message
126
- error_msg = e.message.to_s
127
-
128
- if error_msg.include?('Could not process image')
129
- raise AdapterError, "Image processing failed: #{error_msg}. Ensure your image is a valid PNG, JPEG, GIF, or WebP format, properly base64-encoded, and under 5MB."
130
- elsif error_msg.include?('image')
131
- raise AdapterError, "Image error: #{error_msg}. Anthropic requires base64-encoded images (URLs are not supported)."
132
- elsif error_msg.include?('rate')
133
- raise AdapterError, "Anthropic rate limit exceeded: #{error_msg}. Please wait and try again."
134
- elsif error_msg.include?('authentication') || error_msg.include?('API key')
135
- raise AdapterError, "Anthropic authentication failed: #{error_msg}. Check your API key."
136
- else
137
- # Generic error handling
138
- raise AdapterError, "Anthropic adapter error: #{e.message}"
139
- end
140
- end
141
- end
142
-
143
- private
144
-
145
- # Enhanced JSON extraction specifically for Claude models
146
- # Handles multiple patterns of markdown-wrapped JSON responses
147
- def extract_json_from_response(content)
148
- return content if content.nil? || content.empty?
149
-
150
- # Pattern 1: ```json blocks
151
- if content.include?('```json')
152
- extracted = content[/```json\s*\n(.*?)\n```/m, 1]
153
- return extracted.strip if extracted
154
- end
155
-
156
- # Pattern 2: ## Output values header
157
- if content.include?('## Output values')
158
- extracted = content.split('## Output values').last
159
- .gsub(/```json\s*\n/, '')
160
- .gsub(/\n```.*/, '')
161
- .strip
162
- return extracted if extracted && !extracted.empty?
163
- end
164
-
165
- # Pattern 3: Generic code blocks (check if it looks like JSON)
166
- if content.include?('```')
167
- extracted = content[/```\s*\n(.*?)\n```/m, 1]
168
- return extracted.strip if extracted && looks_like_json?(extracted)
169
- end
170
-
171
- # Pattern 4: Already valid JSON or fallback
172
- content.strip
173
- end
174
-
175
- # Simple heuristic to check if content looks like JSON
176
- def looks_like_json?(str)
177
- return false if str.nil? || str.empty?
178
- trimmed = str.strip
179
- (trimmed.start_with?('{') && trimmed.end_with?('}')) ||
180
- (trimmed.start_with?('[') && trimmed.end_with?(']'))
181
- end
182
-
183
- # Prepare messages for JSON output by adding prefilling and strong instructions
184
- def prepare_messages_for_json(user_messages, system_message)
185
- return user_messages unless requires_json_output?(user_messages, system_message)
186
- return user_messages unless tends_to_wrap_json?
187
-
188
- # Add strong JSON instruction to the last user message if not already present
189
- enhanced_messages = enhance_json_instructions(user_messages)
190
-
191
- # Only add prefill for models that support it and temporarily disable for testing
192
- if false # supports_prefilling? - temporarily disabled
193
- add_json_prefill(enhanced_messages)
194
- else
195
- enhanced_messages
196
- end
197
- end
198
-
199
- # Detect if the conversation requires JSON output
200
- def requires_json_output?(user_messages, system_message)
201
- # Check for JSON-related keywords in messages
202
- all_content = [system_message] + user_messages.map { |m| m[:content] }
203
- all_content.compact.any? do |content|
204
- content.downcase.include?('json') ||
205
- content.include?('```') ||
206
- content.include?('{') ||
207
- content.include?('output')
208
- end
209
- end
210
-
211
- # Check if this is a Claude model that benefits from prefilling
212
- def supports_prefilling?
213
- # Claude models that work well with JSON prefilling
214
- model.downcase.include?('claude')
215
- end
216
-
217
- # Check if this is a Claude model that tends to wrap JSON in markdown
218
- def tends_to_wrap_json?
219
- # All Claude models have this tendency, especially Opus variants
220
- model.downcase.include?('claude')
221
- end
222
-
223
- # Enhance the last user message with strong JSON instructions
224
- def enhance_json_instructions(user_messages)
225
- return user_messages if user_messages.empty?
226
-
227
- enhanced_messages = user_messages.dup
228
- last_message = enhanced_messages.last
229
-
230
- # Only add instruction if not already present
231
- unless last_message[:content].include?('ONLY valid JSON')
232
- # Use smart default instruction for Claude models
233
- json_instruction = "\n\nIMPORTANT: Respond with ONLY valid JSON. No markdown formatting, no code blocks, no explanations. Start your response with '{' and end with '}'."
234
-
235
- last_message = last_message.dup
236
- last_message[:content] = last_message[:content] + json_instruction
237
- enhanced_messages[-1] = last_message
238
- end
239
-
240
- enhanced_messages
241
- end
242
-
243
- # Add assistant message prefill to guide Claude
244
- def add_json_prefill(user_messages)
245
- user_messages + [{ role: "assistant", content: "{" }]
246
- end
247
-
248
- def extract_system_message(messages)
249
- system_message = nil
250
- user_messages = []
251
-
252
- messages.each do |msg|
253
- if msg[:role] == 'system'
254
- system_message = msg[:content]
255
- else
256
- user_messages << msg
257
- end
258
- end
259
-
260
- [system_message, user_messages]
261
- end
262
-
263
- def format_multimodal_messages(messages)
264
- messages.map do |msg|
265
- if msg[:content].is_a?(Array)
266
- # Convert multimodal content to Anthropic format
267
- formatted_content = msg[:content].map do |item|
268
- case item[:type]
269
- when 'text'
270
- { type: 'text', text: item[:text] }
271
- when 'image'
272
- # Validate image compatibility before formatting
273
- item[:image].validate_for_provider!('anthropic')
274
- item[:image].to_anthropic_format
275
- else
276
- item
277
- end
278
- end
279
-
280
- {
281
- role: msg[:role],
282
- content: formatted_content
283
- }
284
- else
285
- msg
286
- end
287
- end
288
- end
289
- end
290
- end
291
- end
@@ -1,186 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "sorbet-runtime"
4
-
5
- module DSPy
6
- class LM
7
- module Adapters
8
- module Gemini
9
- # Converts DSPy signatures to Gemini structured output format
10
- class SchemaConverter
11
- extend T::Sig
12
-
13
- # Models that support structured outputs (JSON + Schema)
14
- # Based on official Google documentation: https://ai.google.dev/gemini-api/docs/models/gemini
15
- # Last updated: Oct 2025
16
- # Note: Gemini 1.5 series deprecated Oct 2025
17
- STRUCTURED_OUTPUT_MODELS = T.let([
18
- # Gemini 2.0 series
19
- "gemini-2.0-flash",
20
- "gemini-2.0-flash-lite",
21
- # Gemini 2.5 series (current)
22
- "gemini-2.5-pro",
23
- "gemini-2.5-flash",
24
- "gemini-2.5-flash-lite",
25
- "gemini-2.5-flash-image"
26
- ].freeze, T::Array[String])
27
-
28
- # Models that do not support structured outputs or are deprecated
29
- UNSUPPORTED_MODELS = T.let([
30
- # Legacy Gemini 1.0 series
31
- "gemini-pro",
32
- "gemini-1.0-pro-002",
33
- "gemini-1.0-pro",
34
- # Deprecated Gemini 1.5 series (removed Oct 2025)
35
- "gemini-1.5-pro",
36
- "gemini-1.5-pro-preview-0514",
37
- "gemini-1.5-pro-preview-0409",
38
- "gemini-1.5-flash",
39
- "gemini-1.5-flash-8b"
40
- ].freeze, T::Array[String])
41
-
42
- sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T::Hash[Symbol, T.untyped]) }
43
- def self.to_gemini_format(signature_class)
44
- # Get the output JSON schema from the signature class
45
- output_schema = signature_class.output_json_schema
46
-
47
- # Convert to Gemini format (OpenAPI 3.0 Schema subset - not related to OpenAI)
48
- convert_dspy_schema_to_gemini(output_schema)
49
- end
50
-
51
- sig { params(model: String).returns(T::Boolean) }
52
- def self.supports_structured_outputs?(model)
53
- # Extract base model name without provider prefix
54
- base_model = model.sub(/^gemini\//, "")
55
-
56
- # Check if it's a supported model or a newer version
57
- STRUCTURED_OUTPUT_MODELS.any? { |supported| base_model.start_with?(supported) }
58
- end
59
-
60
- sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
61
- def self.validate_compatibility(schema)
62
- issues = []
63
-
64
- # Check for deeply nested objects (Gemini has depth limits)
65
- depth = calculate_depth(schema)
66
- if depth > 5
67
- issues << "Schema depth (#{depth}) exceeds recommended limit of 5 levels"
68
- end
69
-
70
- issues
71
- end
72
-
73
- private
74
-
75
- sig { params(dspy_schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
76
- def self.convert_dspy_schema_to_gemini(dspy_schema)
77
- # For Gemini's responseJsonSchema, we need pure JSON Schema format
78
- # Remove OpenAPI-specific fields like "$schema"
79
- result = {
80
- type: "object",
81
- properties: {},
82
- required: []
83
- }
84
-
85
- # Convert properties
86
- properties = dspy_schema[:properties] || {}
87
- properties.each do |prop_name, prop_schema|
88
- result[:properties][prop_name] = convert_property_to_gemini(prop_schema)
89
- end
90
-
91
- # Set required fields
92
- result[:required] = (dspy_schema[:required] || []).map(&:to_s)
93
-
94
- result
95
- end
96
-
97
- sig { params(property_schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
98
- def self.convert_property_to_gemini(property_schema)
99
- # Handle oneOf/anyOf schemas (union types) - Gemini supports these in responseJsonSchema
100
- if property_schema[:oneOf]
101
- return {
102
- oneOf: property_schema[:oneOf].map { |schema| convert_property_to_gemini(schema) },
103
- description: property_schema[:description]
104
- }.compact
105
- end
106
-
107
- if property_schema[:anyOf]
108
- return {
109
- anyOf: property_schema[:anyOf].map { |schema| convert_property_to_gemini(schema) },
110
- description: property_schema[:description]
111
- }.compact
112
- end
113
-
114
- case property_schema[:type]
115
- when "string"
116
- result = { type: "string" }
117
- # Gemini responseJsonSchema doesn't support const, so convert to single-value enum
118
- # See: https://ai.google.dev/api/generate-content#FIELDS.response_json_schema
119
- if property_schema[:const]
120
- result[:enum] = [property_schema[:const]]
121
- elsif property_schema[:enum]
122
- result[:enum] = property_schema[:enum]
123
- end
124
- result
125
- when "integer"
126
- { type: "integer" }
127
- when "number"
128
- { type: "number" }
129
- when "boolean"
130
- { type: "boolean" }
131
- when "array"
132
- {
133
- type: "array",
134
- items: convert_property_to_gemini(property_schema[:items] || { type: "string" })
135
- }
136
- when "object"
137
- result = { type: "object" }
138
-
139
- if property_schema[:properties]
140
- result[:properties] = {}
141
- property_schema[:properties].each do |nested_prop, nested_schema|
142
- result[:properties][nested_prop] = convert_property_to_gemini(nested_schema)
143
- end
144
-
145
- # Set required fields for nested objects
146
- if property_schema[:required]
147
- result[:required] = property_schema[:required].map(&:to_s)
148
- end
149
- end
150
-
151
- result
152
- else
153
- # Default to string for unknown types
154
- { type: "string" }
155
- end
156
- end
157
-
158
- sig { params(schema: T::Hash[Symbol, T.untyped], current_depth: Integer).returns(Integer) }
159
- def self.calculate_depth(schema, current_depth = 0)
160
- return current_depth unless schema.is_a?(Hash)
161
-
162
- max_depth = current_depth
163
-
164
- # Check properties
165
- if schema[:properties].is_a?(Hash)
166
- schema[:properties].each_value do |prop|
167
- if prop.is_a?(Hash)
168
- prop_depth = calculate_depth(prop, current_depth + 1)
169
- max_depth = [max_depth, prop_depth].max
170
- end
171
- end
172
- end
173
-
174
- # Check array items
175
- if schema[:items].is_a?(Hash)
176
- items_depth = calculate_depth(schema[:items], current_depth + 1)
177
- max_depth = [max_depth, items_depth].max
178
- end
179
-
180
- max_depth
181
- end
182
- end
183
- end
184
- end
185
- end
186
- end
@@ -1,220 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'gemini-ai'
4
- require 'json'
5
- require_relative '../vision_models'
6
-
7
- module DSPy
8
- class LM
9
- class GeminiAdapter < Adapter
10
- def initialize(model:, api_key:, structured_outputs: false)
11
- super(model: model, api_key: api_key)
12
- validate_api_key!(api_key, 'gemini')
13
-
14
- @structured_outputs_enabled = structured_outputs
15
-
16
- # Disable streaming for VCR tests since SSE responses don't record properly
17
- # But keep streaming enabled for SSEVCR tests (SSE-specific cassettes)
18
- @use_streaming = true
19
- begin
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
25
- rescue
26
- # If VCR/SSEVCR is not available or any error occurs, use streaming
27
- @use_streaming = true
28
- end
29
-
30
- @client = Gemini.new(
31
- credentials: {
32
- service: 'generative-language-api',
33
- api_key: api_key,
34
- version: 'v1beta' # Use beta API version for structured outputs support
35
- },
36
- options: {
37
- model: model,
38
- server_sent_events: @use_streaming
39
- }
40
- )
41
- end
42
-
43
- def chat(messages:, signature: nil, **extra_params, &block)
44
- normalized_messages = normalize_messages(messages)
45
-
46
- # Validate vision support if images are present
47
- if contains_images?(normalized_messages)
48
- VisionModels.validate_vision_support!('gemini', model)
49
- # Convert messages to Gemini format with proper image handling
50
- normalized_messages = format_multimodal_messages(normalized_messages)
51
- end
52
-
53
- # Convert DSPy message format to Gemini format
54
- gemini_messages = convert_messages_to_gemini_format(normalized_messages)
55
-
56
- request_params = {
57
- contents: gemini_messages
58
- }.merge(extra_params)
59
-
60
- begin
61
- content = ""
62
- final_response_data = nil
63
-
64
- # Check if we're using streaming or not
65
- if @use_streaming
66
- # Streaming mode
67
- @client.stream_generate_content(request_params) do |chunk|
68
- # Handle case where chunk might be a string (from SSE VCR)
69
- if chunk.is_a?(String)
70
- begin
71
- chunk = JSON.parse(chunk)
72
- rescue JSON::ParserError => e
73
- raise AdapterError, "Failed to parse Gemini streaming response: #{e.message}"
74
- end
75
- end
76
-
77
- # Extract content from chunks
78
- if chunk.dig('candidates', 0, 'content', 'parts')
79
- chunk_text = extract_text_from_parts(chunk.dig('candidates', 0, 'content', 'parts'))
80
- content += chunk_text
81
-
82
- # Call block only if provided (for real streaming)
83
- block.call(chunk) if block_given?
84
- end
85
-
86
- # Store final response data (usage, metadata) from last chunk
87
- if chunk['usageMetadata'] || chunk.dig('candidates', 0, 'finishReason')
88
- final_response_data = chunk
89
- end
90
- end
91
- else
92
- # Non-streaming mode (for VCR tests)
93
- response = @client.generate_content(request_params)
94
-
95
- # Extract content from single response
96
- if response.dig('candidates', 0, 'content', 'parts')
97
- content = extract_text_from_parts(response.dig('candidates', 0, 'content', 'parts'))
98
- end
99
-
100
- # Use response as final data
101
- final_response_data = response
102
- end
103
-
104
- # Extract usage information from final chunk
105
- usage_data = final_response_data&.dig('usageMetadata')
106
- usage_struct = usage_data ? UsageFactory.create('gemini', usage_data) : nil
107
-
108
- # Create metadata from final chunk
109
- metadata = {
110
- provider: 'gemini',
111
- model: model,
112
- finish_reason: final_response_data&.dig('candidates', 0, 'finishReason'),
113
- safety_ratings: final_response_data&.dig('candidates', 0, 'safetyRatings'),
114
- streaming: block_given?
115
- }
116
-
117
- # Create typed metadata
118
- typed_metadata = ResponseMetadataFactory.create('gemini', metadata)
119
-
120
- Response.new(
121
- content: content,
122
- usage: usage_struct,
123
- metadata: typed_metadata
124
- )
125
- rescue => e
126
- handle_gemini_error(e)
127
- end
128
- end
129
-
130
- private
131
-
132
- # Convert DSPy message format to Gemini format
133
- def convert_messages_to_gemini_format(messages)
134
- # Gemini expects contents array with role and parts
135
- messages.map do |msg|
136
- role = case msg[:role]
137
- when 'system'
138
- 'user' # Gemini doesn't have explicit system role, merge with user
139
- when 'assistant'
140
- 'model'
141
- else
142
- msg[:role]
143
- end
144
-
145
- if msg[:content].is_a?(Array)
146
- # Multimodal content
147
- parts = msg[:content].map do |item|
148
- case item[:type]
149
- when 'text'
150
- { text: item[:text] }
151
- when 'image'
152
- item[:image].to_gemini_format
153
- else
154
- item
155
- end
156
- end
157
-
158
- { role: role, parts: parts }
159
- else
160
- # Text-only content
161
- { role: role, parts: [{ text: msg[:content] }] }
162
- end
163
- end
164
- end
165
-
166
- # Extract text content from Gemini parts array
167
- def extract_text_from_parts(parts)
168
- return "" unless parts.is_a?(Array)
169
-
170
- parts.map { |part| part['text'] }.compact.join
171
- end
172
-
173
- # Format multimodal messages for Gemini
174
- def format_multimodal_messages(messages)
175
- messages.map do |msg|
176
- if msg[:content].is_a?(Array)
177
- # Convert multimodal content to Gemini format
178
- formatted_content = msg[:content].map do |item|
179
- case item[:type]
180
- when 'text'
181
- { type: 'text', text: item[:text] }
182
- when 'image'
183
- # Validate image compatibility before formatting
184
- item[:image].validate_for_provider!('gemini')
185
- item[:image].to_gemini_format
186
- else
187
- item
188
- end
189
- end
190
-
191
- {
192
- role: msg[:role],
193
- content: formatted_content
194
- }
195
- else
196
- msg
197
- end
198
- end
199
- end
200
-
201
- # Handle Gemini-specific errors
202
- def handle_gemini_error(error)
203
- error_msg = error.message.to_s
204
-
205
- if error_msg.include?('API_KEY') || error_msg.include?('status 400') || error_msg.include?('status 401') || error_msg.include?('status 403')
206
- raise AdapterError, "Gemini authentication failed: #{error_msg}. Check your API key."
207
- elsif error_msg.include?('RATE_LIMIT') || error_msg.downcase.include?('quota') || error_msg.include?('status 429')
208
- raise AdapterError, "Gemini rate limit exceeded: #{error_msg}. Please wait and try again."
209
- elsif error_msg.include?('SAFETY') || error_msg.include?('blocked')
210
- raise AdapterError, "Gemini content was blocked by safety filters: #{error_msg}"
211
- elsif error_msg.include?('image') || error_msg.include?('media')
212
- raise AdapterError, "Gemini image processing failed: #{error_msg}. Ensure your image is a valid format and under size limits."
213
- else
214
- # Generic error handling
215
- raise AdapterError, "Gemini adapter error: #{error_msg}"
216
- end
217
- end
218
- end
219
- end
220
- end