dspy 0.34.2 → 0.34.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -16
  3. data/lib/dspy/chain_of_thought.rb +3 -2
  4. data/lib/dspy/context.rb +70 -21
  5. data/lib/dspy/evals/version.rb +1 -1
  6. data/lib/dspy/evals.rb +42 -31
  7. data/lib/dspy/events.rb +2 -3
  8. data/lib/dspy/example.rb +1 -1
  9. data/lib/dspy/lm/adapter.rb +39 -0
  10. data/lib/dspy/lm/json_strategy.rb +28 -67
  11. data/lib/dspy/lm/message.rb +1 -1
  12. data/lib/dspy/lm/response.rb +2 -2
  13. data/lib/dspy/lm/usage.rb +35 -10
  14. data/lib/dspy/lm.rb +22 -51
  15. data/lib/dspy/mixins/type_coercion.rb +256 -35
  16. data/lib/dspy/module.rb +203 -31
  17. data/lib/dspy/predict.rb +33 -6
  18. data/lib/dspy/prediction.rb +25 -58
  19. data/lib/dspy/prompt.rb +52 -76
  20. data/lib/dspy/propose/dataset_summary_generator.rb +1 -1
  21. data/lib/dspy/propose/grounded_proposer.rb +3 -3
  22. data/lib/dspy/re_act.rb +159 -196
  23. data/lib/dspy/registry/signature_registry.rb +3 -3
  24. data/lib/dspy/ruby_llm/lm/adapters/ruby_llm_adapter.rb +1 -27
  25. data/lib/dspy/schema/sorbet_json_schema.rb +7 -6
  26. data/lib/dspy/schema/version.rb +1 -1
  27. data/lib/dspy/schema_adapters.rb +1 -1
  28. data/lib/dspy/signature.rb +4 -5
  29. data/lib/dspy/storage/program_storage.rb +2 -2
  30. data/lib/dspy/structured_outputs_prompt.rb +4 -4
  31. data/lib/dspy/teleprompt/utils.rb +2 -2
  32. data/lib/dspy/tools/github_cli_toolset.rb +7 -7
  33. data/lib/dspy/tools/text_processing_toolset.rb +2 -2
  34. data/lib/dspy/tools/toolset.rb +1 -1
  35. data/lib/dspy/utils/serialization.rb +2 -6
  36. data/lib/dspy/version.rb +1 -1
  37. data/lib/dspy.rb +50 -5
  38. metadata +7 -26
  39. data/lib/dspy/events/subscriber_mixin.rb +0 -79
  40. data/lib/dspy/events/subscribers.rb +0 -43
  41. data/lib/dspy/memory/embedding_engine.rb +0 -68
  42. data/lib/dspy/memory/in_memory_store.rb +0 -216
  43. data/lib/dspy/memory/local_embedding_engine.rb +0 -244
  44. data/lib/dspy/memory/memory_compactor.rb +0 -298
  45. data/lib/dspy/memory/memory_manager.rb +0 -266
  46. data/lib/dspy/memory/memory_record.rb +0 -163
  47. data/lib/dspy/memory/memory_store.rb +0 -90
  48. data/lib/dspy/memory.rb +0 -30
  49. data/lib/dspy/tools/memory_toolset.rb +0 -117
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fe4cbffffd520f31219caa7dbce4b75ef39684cdd5e422b60144aa3ae3329e9
4
- data.tar.gz: '0974465586686c0b93292b464f9b49abcb10a265ca85295c65584e989a271d7f'
3
+ metadata.gz: 5d5296e0130d0550156345659e6703451bd6ca6fb9ffbac33d8584ee203f2a84
4
+ data.tar.gz: 339772eb768a2babbb8b700b868fc772183473b7cf4f8a6867e55945f75a3655
5
5
  SHA512:
6
- metadata.gz: 5602b8a59a8454306921a2528ccb58824cfe3878a4d31f3b8b4b93d94730c6f2e804c439a9fd1f7bb44a2afe6aff6cb3843e1d7ad6f45c51e4e01aec7d44db5c
7
- data.tar.gz: 4bf2656ade52cbf55e6416a061e9107c9332a0e669b9bafe07acf045aac605eaa38410ae433268b78a7d285b47450e03e8416e9a456d978e258177fce00a90e9
6
+ metadata.gz: 827360cba1ad8d03373d40d9b1b9ce3acf966e896261b918537150568cf4635904e591c238cdea7d8e8bf45980851ce3c581f709639215c8d7e4c7e1ea78dc08
7
+ data.tar.gz: e8d91df3e7204ac0d0db830c5839a4cbdd9683a00be2a77dec38de156052f0a0b3956e25c8b7026fcce23880e9806fc9af023fc2a4fe830926f288f82474a85b
data/README.md CHANGED
@@ -137,26 +137,18 @@ result.answer # => "60 km/h"
137
137
  Build agents that use tools to accomplish tasks:
138
138
 
139
139
  ```ruby
140
- class SearchTool < DSPy::Tools::Tool
140
+ class SearchTool < DSPy::Tools::Base
141
141
  tool_name "search"
142
- description "Search for information"
143
-
144
- input do
145
- const :query, String
146
- end
147
-
148
- output do
149
- const :results, T::Array[String]
150
- end
142
+ tool_description "Search for information"
151
143
 
144
+ sig { params(query: String).returns(String) }
152
145
  def call(query:)
153
146
  # Your search implementation
154
- { results: ["Result 1", "Result 2"] }
147
+ "Result 1, Result 2"
155
148
  end
156
149
  end
157
150
 
158
- toolset = DSPy::Tools::Toolset.new(tools: [SearchTool.new])
159
- agent = DSPy::ReAct.new(signature: ResearchTask, tools: toolset, max_iterations: 5)
151
+ agent = DSPy::ReAct.new(ResearchTask, tools: [SearchTool.new], max_iterations: 5)
160
152
  result = agent.call(question: "What's the latest on Ruby 3.4?")
161
153
  ```
162
154
 
@@ -185,8 +177,8 @@ result = agent.call(question: "What's the latest on Ruby 3.4?")
185
177
  A [Claude Skill](https://github.com/vicentereig/dspy-rb-skill) is available to help you build DSPy.rb applications:
186
178
 
187
179
  ```bash
188
- # Claude Code
189
- git clone https://github.com/vicentereig/dspy-rb-skill ~/.claude/skills/dspy-rb
180
+ # Claude Code — install from the vicentereig/engineering marketplace
181
+ claude install-skill vicentereig/engineering --skill dspy-rb
190
182
  ```
191
183
 
192
184
  For Claude.ai Pro/Max, download the [skill ZIP](https://github.com/vicentereig/dspy-rb-skill/archive/refs/heads/main.zip) and upload via Settings > Skills.
@@ -201,7 +193,7 @@ The [examples/](examples/) directory has runnable code for common patterns:
201
193
  - Prompt optimization
202
194
 
203
195
  ```bash
204
- bundle exec ruby examples/first_predictor.rb
196
+ bundle exec ruby examples/basic_search_agent.rb
205
197
  ```
206
198
 
207
199
  ## Optional Gems
@@ -47,7 +47,8 @@ module DSPy
47
47
  output_schema: @signature_class.output_json_schema,
48
48
  few_shot_examples: new_prompt.few_shot_examples,
49
49
  signature_class_name: @signature_class.name,
50
- schema_format: new_prompt.schema_format
50
+ schema_format: new_prompt.schema_format,
51
+ data_format: new_prompt.data_format
51
52
  )
52
53
 
53
54
  instance.instance_variable_set(:@prompt, enhanced_prompt)
@@ -93,7 +94,7 @@ module DSPy
93
94
 
94
95
  # Create a temporary Predict instance with our enhanced signature to get the prediction
95
96
  predict_instance = DSPy::Predict.new(@signature_class)
96
- predict_instance.config.lm = self.lm # Use the same LM configuration
97
+ predict_instance.configure { |c| c.lm = self.lm } # Use the same LM configuration
97
98
 
98
99
  # Call predict's forward method, which will create the Predict span
99
100
  prediction_result = predict_instance.forward(**input_values)
data/lib/dspy/context.rb CHANGED
@@ -31,6 +31,18 @@ module DSPy
31
31
  context
32
32
  end
33
33
 
34
+ def with_request(request_id, start_time)
35
+ previous_request_id = current[:request_id]
36
+ previous_start_time = current[:request_start_time]
37
+
38
+ current[:request_id] = request_id
39
+ current[:request_start_time] = start_time
40
+ yield
41
+ ensure
42
+ current[:request_id] = previous_request_id
43
+ current[:request_start_time] = previous_start_time
44
+ end
45
+
34
46
  def fork_context(parent_context)
35
47
  clone_context(parent_context)
36
48
  end
@@ -62,8 +74,9 @@ module DSPy
62
74
  # Prepare attributes and add trace name for root spans
63
75
  span_attributes = sanitized_attributes.transform_keys(&:to_s).reject { |k, v| v.nil? }
64
76
 
65
- # Set trace name if this is likely a root span (no parent in our stack)
66
- if current[:span_stack].length == 1 # This will be the first span
77
+ # Set trace name if this is likely a root span (no parent in our stack),
78
+ # unless callers already specified one explicitly.
79
+ if current[:span_stack].length == 1 && !span_attributes.key?('langfuse.trace.name')
67
80
  span_attributes['langfuse.trace.name'] = operation
68
81
  end
69
82
 
@@ -72,6 +85,12 @@ module DSPy
72
85
 
73
86
  # Get parent OpenTelemetry span for proper context propagation
74
87
  parent_otel_span = current[:otel_span_stack].last
88
+ if !parent_otel_span && defined?(OpenTelemetry::Trace)
89
+ current_span = OpenTelemetry::Trace.current_span
90
+ if current_span && current_span != OpenTelemetry::Trace::Span::INVALID
91
+ parent_otel_span = current_span
92
+ end
93
+ end
75
94
 
76
95
  # Create span with proper parent context
77
96
  if parent_otel_span
@@ -84,20 +103,18 @@ module DSPy
84
103
  ) do |span|
85
104
  # Add to our OpenTelemetry span stack
86
105
  current[:otel_span_stack].push(span)
106
+ succeeded = false
87
107
 
88
108
  begin
89
109
  result = yield(span)
90
-
91
- # Add explicit timing information to help Langfuse
92
- if span
93
- duration_ms = ((Time.now - otel_start_time) * 1000).round(3)
94
- span.set_attribute('duration.ms', duration_ms)
95
- span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
96
- span.set_attribute('langfuse.observation.endTime', Time.now.iso8601(3))
97
- end
98
-
110
+ succeeded = true
99
111
  result
112
+ rescue StandardError => e
113
+ set_span_error_attributes(span, e)
114
+ raise
100
115
  ensure
116
+ set_span_status_attribute(span, succeeded)
117
+ set_span_timing_attributes(span, otel_start_time)
101
118
  # Remove from our OpenTelemetry span stack
102
119
  current[:otel_span_stack].pop
103
120
  end
@@ -112,20 +129,18 @@ module DSPy
112
129
  ) do |span|
113
130
  # Add to our OpenTelemetry span stack
114
131
  current[:otel_span_stack].push(span)
132
+ succeeded = false
115
133
 
116
134
  begin
117
135
  result = yield(span)
118
-
119
- # Add explicit timing information to help Langfuse
120
- if span
121
- duration_ms = ((Time.now - otel_start_time) * 1000).round(3)
122
- span.set_attribute('duration.ms', duration_ms)
123
- span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
124
- span.set_attribute('langfuse.observation.endTime', Time.now.iso8601(3))
125
- end
126
-
136
+ succeeded = true
127
137
  result
138
+ rescue StandardError => e
139
+ set_span_error_attributes(span, e)
140
+ raise
128
141
  ensure
142
+ set_span_status_attribute(span, succeeded)
143
+ set_span_timing_attributes(span, otel_start_time)
129
144
  # Remove from our OpenTelemetry span stack
130
145
  current[:otel_span_stack].pop
131
146
  end
@@ -216,7 +231,9 @@ module DSPy
216
231
  fiber_id: Fiber.current.object_id,
217
232
  span_stack: [],
218
233
  otel_span_stack: [],
219
- module_stack: []
234
+ module_stack: [],
235
+ request_id: nil,
236
+ request_start_time: nil
220
237
  }
221
238
  end
222
239
 
@@ -227,6 +244,8 @@ module DSPy
227
244
  cloned[:module_stack] = Array(context[:module_stack]).map { |entry| entry.dup }
228
245
  cloned[:thread_id] = Thread.current.object_id
229
246
  cloned[:fiber_id] = Fiber.current.object_id
247
+ cloned[:request_id] = context[:request_id]
248
+ cloned[:request_start_time] = context[:request_start_time]
230
249
  cloned
231
250
  end
232
251
 
@@ -280,6 +299,36 @@ module DSPy
280
299
  label: explicit_label || (module_instance.respond_to?(:module_scope_label) ? module_instance.module_scope_label : nil)
281
300
  }
282
301
  end
302
+
303
+ def set_span_timing_attributes(span, otel_start_time)
304
+ return unless span
305
+
306
+ now = Time.now
307
+ duration_ms = ((now - otel_start_time) * 1000).round(3)
308
+ span.set_attribute('duration.ms', duration_ms)
309
+ span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
310
+ span.set_attribute('langfuse.observation.endTime', now.iso8601(3))
311
+ rescue StandardError
312
+ nil
313
+ end
314
+
315
+ def set_span_error_attributes(span, error)
316
+ return unless span
317
+
318
+ span.set_attribute('error', true)
319
+ span.set_attribute('error.type', error.class.name)
320
+ span.set_attribute('error.message', error.message.to_s[0, 2000]) if error.message
321
+ rescue StandardError
322
+ nil
323
+ end
324
+
325
+ def set_span_status_attribute(span, succeeded)
326
+ return unless span
327
+
328
+ span.set_attribute('dspy.status', succeeded ? 'completed' : 'error')
329
+ rescue StandardError
330
+ nil
331
+ end
283
332
  end
284
333
  end
285
334
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module DSPy
4
4
  class Evals
5
- VERSION = '1.0.1'
5
+ VERSION = '1.0.2'
6
6
  end
7
7
  end
data/lib/dspy/evals.rb CHANGED
@@ -254,25 +254,7 @@ module DSPy
254
254
  # Evaluate program on a single example
255
255
  sig { params(example: T.untyped, trace: T.nilable(T.untyped)).returns(EvaluationResult) }
256
256
  def call(example, trace: nil)
257
- run_callbacks(:before, :call, example: example)
258
-
259
- DSPy::Context.with_span(
260
- operation: 'evaluation.example',
261
- 'dspy.module' => 'Evaluator',
262
- 'evaluation.program' => @program.class.name,
263
- 'evaluation.has_metric' => !@metric.nil?
264
- ) do
265
- begin
266
- perform_call(example, trace: trace)
267
- rescue => e
268
- build_error_result(example, e, trace: trace)
269
- end
270
- end.then do |result|
271
- @last_example_result = result
272
- emit_example_observation(example, result)
273
- run_callbacks(:after, :call, example: example, result: result)
274
- result
275
- end
257
+ call_with_program(@program, example, trace: trace, track_state: true)
276
258
  end
277
259
 
278
260
  # Evaluate program on multiple examples
@@ -403,8 +385,9 @@ module DSPy
403
385
 
404
386
  futures = batch.map do |item|
405
387
  Concurrent::Promises.future_on(executor) do
406
- [:ok, item[:index], safe_call(item[:example])]
407
- rescue => e
388
+ program_for_thread = fork_program_for_thread
389
+ [:ok, item[:index], safe_call(item[:example], program: program_for_thread, track_state: false)]
390
+ rescue StandardError => e
408
391
  [:error, item[:index], e]
409
392
  end
410
393
  end
@@ -441,18 +424,18 @@ module DSPy
441
424
  results.compact
442
425
  end
443
426
 
444
- def safe_call(example)
445
- call(example)
446
- rescue => e
427
+ def safe_call(example, program: @program, track_state: true)
428
+ call_with_program(program, example, track_state: track_state)
429
+ rescue StandardError => e
447
430
  build_error_result(example, e)
448
431
  end
449
432
 
450
- def perform_call(example, trace:)
433
+ def perform_call(example, trace:, program:)
451
434
  # Extract input from example - support both hash and object formats
452
435
  input_values = extract_input_values(example)
453
436
 
454
437
  # Run prediction
455
- prediction = @program.call(**input_values)
438
+ prediction = program.call(**input_values)
456
439
 
457
440
  # Calculate metrics if provided
458
441
  metrics = {}
@@ -469,7 +452,7 @@ module DSPy
469
452
  passed = !!metric_result
470
453
  metrics[:passed] = passed
471
454
  end
472
- rescue => e
455
+ rescue StandardError => e
473
456
  passed = false
474
457
  metrics[:error] = e.message
475
458
  metrics[:passed] = false
@@ -490,6 +473,34 @@ module DSPy
490
473
  )
491
474
  end
492
475
 
476
+ def call_with_program(program, example, trace: nil, track_state: true)
477
+ run_callbacks(:before, :call, example: example)
478
+
479
+ DSPy::Context.with_span(
480
+ operation: 'evaluation.example',
481
+ 'dspy.module' => 'Evaluator',
482
+ 'evaluation.program' => program.class.name,
483
+ 'evaluation.has_metric' => !@metric.nil?
484
+ ) do
485
+ begin
486
+ perform_call(example, trace: trace, program: program)
487
+ rescue StandardError => e
488
+ build_error_result(example, e, trace: trace)
489
+ end
490
+ end.then do |result|
491
+ @last_example_result = result if track_state
492
+ emit_example_observation(example, result)
493
+ run_callbacks(:after, :call, example: example, result: result)
494
+ result
495
+ end
496
+ end
497
+
498
+ def fork_program_for_thread
499
+ return @program if @program.nil?
500
+ return @program.dup_for_thread if @program.respond_to?(:dup_for_thread)
501
+ @program.dup
502
+ end
503
+
493
504
  def build_error_result(example, error, trace: nil)
494
505
  metrics = {
495
506
  error: error.message,
@@ -680,7 +691,7 @@ module DSPy
680
691
  if @export_scores
681
692
  export_example_score(example, result)
682
693
  end
683
- rescue => e
694
+ rescue StandardError => e
684
695
  DSPy.log('evals.example.observation_error', error: e.message)
685
696
  end
686
697
 
@@ -698,7 +709,7 @@ module DSPy
698
709
  if @export_scores
699
710
  export_batch_score(batch_result)
700
711
  end
701
- rescue => e
712
+ rescue StandardError => e
702
713
  DSPy.log('evals.batch.observation_error', error: e.message)
703
714
  end
704
715
 
@@ -711,7 +722,7 @@ module DSPy
711
722
  score_value,
712
723
  comment: "Example: #{example_id || 'unknown'}, passed: #{result.passed}"
713
724
  )
714
- rescue => e
725
+ rescue StandardError => e
715
726
  DSPy.log('evals.score_export_error', error: e.message)
716
727
  end
717
728
 
@@ -721,7 +732,7 @@ module DSPy
721
732
  batch_result.pass_rate,
722
733
  comment: "Batch: #{batch_result.passed_examples}/#{batch_result.total_examples} passed"
723
734
  )
724
- rescue => e
735
+ rescue StandardError => e
725
736
  DSPy.log('evals.batch_score_export_error', error: e.message)
726
737
  end
727
738
 
data/lib/dspy/events.rb CHANGED
@@ -11,7 +11,6 @@ module DSPy
11
11
  class EventRegistry
12
12
  def initialize
13
13
  @listeners = {}
14
- @subscription_counter = 0
15
14
  @mutex = Mutex.new
16
15
  end
17
16
 
@@ -53,7 +52,7 @@ module DSPy
53
52
  matching_listeners.each do |id, listener|
54
53
  begin
55
54
  listener[:block].call(event_name, attributes)
56
- rescue => e
55
+ rescue StandardError => e
57
56
  # Log the error but continue processing other listeners
58
57
  # Use emit_log directly to avoid infinite recursion
59
58
  DSPy.send(:emit_log, 'event.listener.error', {
@@ -80,4 +79,4 @@ module DSPy
80
79
  end
81
80
  end
82
81
  end
83
- end
82
+ end
data/lib/dspy/example.rb CHANGED
@@ -178,7 +178,7 @@ module DSPy
178
178
  id: example_data[:id] || "example_#{index}"
179
179
  )
180
180
  examples << example
181
- rescue => e
181
+ rescue StandardError => e
182
182
  errors << "Example #{index}: #{e.message}"
183
183
  end
184
184
  end
@@ -57,6 +57,45 @@ module DSPy
57
57
  content.is_a?(Array) && content.any? { |item| item[:type] == 'image' }
58
58
  end
59
59
  end
60
+
61
+ # Format multimodal messages for a specific provider
62
+ # @param messages [Array<Hash>] Array of message hashes
63
+ # @param provider_name [String] Provider name for image validation and formatting
64
+ # @return [Array<Hash>] Messages with images formatted for the provider
65
+ def format_multimodal_messages(messages, provider_name)
66
+ messages.map do |msg|
67
+ if msg[:content].is_a?(Array)
68
+ formatted_content = msg[:content].map do |item|
69
+ case item[:type]
70
+ when 'text'
71
+ { type: 'text', text: item[:text] }
72
+ when 'image'
73
+ format_image_for_provider(item[:image], provider_name)
74
+ else
75
+ item
76
+ end
77
+ end
78
+ { role: msg[:role], content: formatted_content }
79
+ else
80
+ msg
81
+ end
82
+ end
83
+ end
84
+
85
+ # Format an image for a specific provider
86
+ # @param image [DSPy::Image] The image to format
87
+ # @param provider_name [String] Provider name (openai, anthropic, gemini, etc.)
88
+ # @return [Hash] Provider-specific image format
89
+ def format_image_for_provider(image, provider_name)
90
+ image.validate_for_provider!(provider_name)
91
+ format_method = "to_#{provider_name}_format"
92
+ if image.respond_to?(format_method)
93
+ image.send(format_method)
94
+ else
95
+ # For providers without specific format methods, return the item as-is
96
+ { type: 'image', image: image }
97
+ end
98
+ end
60
99
  end
61
100
  end
62
101
  end
@@ -38,17 +38,8 @@ module DSPy
38
38
  # OpenAI/Ollama: try to extract JSON from various formats
39
39
  extract_json_from_content(response.content)
40
40
  elsif adapter_class_name.include?('AnthropicAdapter')
41
- # Anthropic: try tool use first if structured_outputs enabled, else use content extraction
42
- structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
43
- structured_outputs_enabled = true if structured_outputs_enabled.nil? # Default to true
44
-
45
- if structured_outputs_enabled
46
- extracted = extract_anthropic_tool_json(response)
47
- extracted || extract_json_from_content(response.content)
48
- else
49
- # Skip tool extraction, use enhanced prompting extraction
50
- extract_json_from_content(response.content)
51
- end
41
+ # Anthropic: Beta API returns JSON in content, same as OpenAI/Gemini
42
+ extract_json_from_content(response.content)
52
43
  elsif adapter_class_name.include?('GeminiAdapter')
53
44
  # Gemini: try to extract JSON from various formats
54
45
  extract_json_from_content(response.content)
@@ -90,25 +81,30 @@ module DSPy
90
81
  # Anthropic preparation
91
82
  sig { params(messages: T::Array[T::Hash[Symbol, T.untyped]], request_params: T::Hash[Symbol, T.untyped]).void }
92
83
  def prepare_anthropic_request(messages, request_params)
93
- # Only use tool-based extraction if structured_outputs is enabled (default: true)
94
- structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
84
+ begin
85
+ require "dspy/anthropic/lm/schema_converter"
86
+ rescue LoadError
87
+ msg = <<~MSG
88
+ Anthropic adapter is optional; structured output helpers will be unavailable until the gem is installed.
89
+ Add `gem 'dspy-anthropic'` to your Gemfile and run `bundle install`.
90
+ MSG
91
+ raise DSPy::LM::MissingAdapterError, msg
92
+ end
95
93
 
96
- # Default to true if not set (backward compatibility)
94
+ # Only use Beta API structured outputs if enabled (default: true)
95
+ structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
97
96
  structured_outputs_enabled = true if structured_outputs_enabled.nil?
98
97
 
99
98
  return unless structured_outputs_enabled
100
99
 
101
- # Convert signature to tool schema
102
- tool_schema = convert_to_anthropic_tool_schema
100
+ # Use Anthropic Beta API structured outputs
101
+ schema = DSPy::Anthropic::LM::SchemaConverter.to_beta_format(signature_class)
103
102
 
104
- # Add tool definition
105
- request_params[:tools] = [tool_schema]
106
-
107
- # Force tool use
108
- request_params[:tool_choice] = {
109
- type: "tool",
110
- name: "json_output"
111
- }
103
+ request_params[:output_format] = ::Anthropic::Models::Beta::BetaJSONOutputFormat.new(
104
+ type: :json_schema,
105
+ schema: schema
106
+ )
107
+ request_params[:betas] = ["structured-outputs-2025-11-13"]
112
108
  end
113
109
 
114
110
  # Gemini preparation
@@ -135,54 +131,19 @@ module DSPy
135
131
  end
136
132
  end
137
133
 
138
- # Convert signature to Anthropic tool schema
139
- sig { returns(T::Hash[Symbol, T.untyped]) }
140
- def convert_to_anthropic_tool_schema
141
- output_fields = signature_class.output_field_descriptors
142
-
143
- {
144
- name: "json_output",
145
- description: "Output the result in the required JSON format",
146
- input_schema: {
147
- type: "object",
148
- properties: build_properties_from_fields(output_fields),
149
- required: output_fields.keys.map(&:to_s)
150
- }
151
- }
152
- end
153
-
154
- # Build JSON schema properties from output fields
155
- sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
156
- def build_properties_from_fields(fields)
157
- properties = {}
158
- fields.each do |field_name, descriptor|
159
- properties[field_name.to_s] = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(descriptor.type)
160
- end
161
- properties
162
- end
163
-
164
- # Extract JSON from Anthropic tool use response
165
- sig { params(response: DSPy::LM::Response).returns(T.nilable(String)) }
166
- def extract_anthropic_tool_json(response)
167
- # Check for tool calls in metadata
168
- if response.metadata.respond_to?(:tool_calls) && response.metadata.tool_calls
169
- tool_calls = response.metadata.tool_calls
170
- if tool_calls.is_a?(Array) && !tool_calls.empty?
171
- first_call = tool_calls.first
172
- if first_call[:name] == "json_output" && first_call[:input]
173
- return JSON.generate(first_call[:input])
174
- end
175
- end
176
- end
177
-
178
- nil
179
- end
180
-
181
134
  # Extract JSON from content that may contain markdown or plain JSON
182
135
  sig { params(content: String).returns(String) }
183
136
  def extract_json_from_content(content)
184
137
  return content if content.nil? || content.empty?
185
138
 
139
+ # Fix Anthropic Beta API bug with optional fields producing invalid JSON
140
+ # When some output fields are optional and not returned, Anthropic's structured outputs
141
+ # can produce trailing comma+brace: {"field1": {...},} instead of {"field1": {...}}
142
+ # This workaround removes the invalid trailing syntax before JSON parsing
143
+ if content =~ /,\s*\}\s*$/
144
+ content = content.sub(/,(\s*\}\s*)$/, '\1')
145
+ end
146
+
186
147
  # Try 1: Check for ```json code block (with or without preceding text)
187
148
  if content.include?('```json')
188
149
  json_match = content.match(/```json\s*\n(.*?)\n```/m)
@@ -154,7 +154,7 @@ module DSPy
154
154
  content: formatted_content,
155
155
  name: data[:name]&.to_s
156
156
  )
157
- rescue => e
157
+ rescue StandardError => e
158
158
  DSPy.logger.debug("Failed to create Message: #{e.message}")
159
159
  nil
160
160
  end
@@ -118,7 +118,7 @@ module DSPy
118
118
  extend T::Sig
119
119
 
120
120
  const :content, String
121
- const :usage, T.nilable(T.any(Usage, OpenAIUsage)), default: nil
121
+ const :usage, T.nilable(T.any(Usage, OpenAIUsage, AnthropicUsage)), default: nil
122
122
  const :metadata, T.any(ResponseMetadata, OpenAIResponseMetadata, AnthropicResponseMetadata, GeminiResponseMetadata, T::Hash[Symbol, T.untyped])
123
123
 
124
124
  sig { returns(String) }
@@ -182,7 +182,7 @@ module DSPy
182
182
  else
183
183
  ResponseMetadata.new(**common_fields)
184
184
  end
185
- rescue => e
185
+ rescue StandardError => e
186
186
  DSPy.logger.debug("Failed to create response metadata: #{e.message}")
187
187
  # Fallback to basic metadata
188
188
  ResponseMetadata.new(