dspy 0.31.0 → 0.32.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: 972e09f00d8d5417d5c1af255eb01503fc33ab370264345b4c7de880b4f99fda
4
- data.tar.gz: 21f6f7952a9caaf8398a24a69516147bf68c88e510b88aa2f45239786bbfd31b
3
+ metadata.gz: a3f15cdf0298a37b8ddf1ffb484e15e01e9bb8f794154d2a993dd91beaa4d22e
4
+ data.tar.gz: f6d0376e92a84d0086257546111e3f456043d814f9dd051a18ce84569e30ff1e
5
5
  SHA512:
6
- metadata.gz: 780f786797df9d50950c1526296c8bc7a0db87dab29078e14de2d32a0ec608ae30585963a4dda3a5261b60530b3dd59e0b9e5915eab6c8d60360c4d1b6e1d8af
7
- data.tar.gz: 3fb5d69bb58d9b57905a6f9985ecdd5d792be0aa91fb5e980f437f5d8887144564697d1151ed9bb8ec742b4ae4c1efff5303b53d937603ac7719959ffb11cfd9
6
+ metadata.gz: 5c26922fcf7fd867b6469bdcae24f3c2937a2f2e89b0583026a478c766c4138896709d98d598814d09220a8becd7f624bc824ad3a9f569bbac60abf7ec4e49d7
7
+ data.tar.gz: a251452f69f157a026e0c23bc912f9ddc2252d4e977e2ebbc3f2276ffd293fe7ff4af7aaa97c7807c21e5cdd805fd50d54046016c44664fb153b87788e5a9a16
data/README.md CHANGED
@@ -13,12 +13,10 @@
13
13
  >
14
14
  > If you want to contribute, feel free to reach out to me to coordinate efforts: hey at vicente.services
15
15
  >
16
- > And, yes, this is 100% a legit project. :)
17
-
18
16
 
19
17
  **Build reliable LLM applications in idiomatic Ruby using composable, type-safe modules.**
20
18
 
21
- DSPy.rb is the Ruby-first surgical port of Stanford's [DSPy framework](https://github.com/stanfordnlp/dspy). It delivers structured LLM programming, prompt engineering, and context engineering in the language we love. Instead of wrestling with brittle prompt strings, you define typed signatures in idiomatic Ruby and compose workflows and agents that actually behave.
19
+ DSPy.rb is the Ruby-first surgical port of Stanford's [DSPy paradigm](https://github.com/stanfordnlp/dspy). It delivers structured LLM programming, prompt engineering, and context engineering in the language we love. Instead of wrestling with brittle prompt strings, you define typed signatures in idiomatic Ruby and compose workflows and agents that actually behave.
22
20
 
23
21
  **Prompts are just functions.** Traditional prompting is like writing code with string concatenation: it works until it doesn't. DSPy.rb brings you the programming approach pioneered by [dspy.ai](https://dspy.ai/): define modular signatures and let the framework deal with the messy bits.
24
22
 
@@ -104,6 +102,7 @@ DSPy.rb ships multiple gems from this monorepo so you can opt into features with
104
102
  | `dspy-openai` | Packages the OpenAI/OpenRouter/Ollama adapters plus the official SDK guardrails. Install whenever you call `openai/*`, `openrouter/*`, or `ollama/*`. [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/openai/README.md) | **Stable** (v1.0.0) |
105
103
  | `dspy-anthropic` | Claude adapters, streaming, and structured-output helpers behind the official `anthropic` SDK. [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/anthropic/README.md) | **Stable** (v1.0.0) |
106
104
  | `dspy-gemini` | Gemini adapters with multimodal + tool-call support via `gemini-ai`. [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/gemini/README.md) | **Stable** (v1.0.0) |
105
+ | `dspy-ruby_llm` | Unified access to 12+ LLM providers (OpenAI, Anthropic, Gemini, Bedrock, Ollama, DeepSeek, etc.) via [RubyLLM](https://rubyllm.com). [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/ruby_llm/README.md) | **Stable** (v0.1.0) |
107
106
  | `dspy-code_act` | Think-Code-Observe agents that synthesize and execute Ruby safely. (Add the gem or set `DSPY_WITH_CODE_ACT=1` before requiring `dspy/code_act`.) | **Stable** (v1.0.0) |
108
107
  | `dspy-datasets` | Dataset helpers plus Parquet/Polars tooling for richer evaluation corpora. (Toggle via `DSPY_WITH_DATASETS`.) | **Stable** (v1.0.0) |
109
108
  | `dspy-evals` | High-throughput evaluation harness with metrics, callbacks, and regression fixtures. (Toggle via `DSPY_WITH_EVALS`.) | **Stable** (v1.0.0) |
@@ -2,6 +2,6 @@
2
2
 
3
3
  module DSPy
4
4
  class Evals
5
- VERSION = '1.0.0'
5
+ VERSION = '1.0.1'
6
6
  end
7
7
  end
@@ -10,10 +10,11 @@ module DSPy
10
10
  'anthropic' => { class_name: 'DSPy::Anthropic::LM::Adapters::AnthropicAdapter', gem_name: 'dspy-anthropic' },
11
11
  'ollama' => { class_name: 'DSPy::OpenAI::LM::Adapters::OllamaAdapter', gem_name: 'dspy-openai' },
12
12
  'gemini' => { class_name: 'DSPy::Gemini::LM::Adapters::GeminiAdapter', gem_name: 'dspy-gemini' },
13
- 'openrouter' => { class_name: 'DSPy::OpenAI::LM::Adapters::OpenRouterAdapter', gem_name: 'dspy-openai' }
13
+ 'openrouter' => { class_name: 'DSPy::OpenAI::LM::Adapters::OpenRouterAdapter', gem_name: 'dspy-openai' },
14
+ 'ruby_llm' => { class_name: 'DSPy::RubyLLM::LM::Adapters::RubyLLMAdapter', gem_name: 'dspy-ruby_llm' }
14
15
  }.freeze
15
16
 
16
- PROVIDERS_WITH_EXTRA_OPTIONS = %w[openai anthropic ollama gemini openrouter].freeze
17
+ PROVIDERS_WITH_EXTRA_OPTIONS = %w[openai anthropic ollama gemini openrouter ruby_llm].freeze
17
18
 
18
19
  class AdapterData < Data.define(:class_name, :gem_name)
19
20
  def self.from_prefix(provider_prefix)
@@ -36,6 +36,8 @@ module DSPy
36
36
  coerce_array_value(value, prop_type)
37
37
  when ->(type) { hash_type?(type) }
38
38
  coerce_hash_value(value, prop_type)
39
+ when ->(type) { type == String || simple_type_match?(type, String) }
40
+ value.to_s
39
41
  when ->(type) { enum_type?(type) }
40
42
  coerce_enum_value(value, prop_type)
41
43
  when ->(type) { type == Float || simple_type_match?(type, Float) }
@@ -26,29 +26,33 @@ rescue LoadError
26
26
  end
27
27
  end
28
28
 
29
- class ObservationType < T::Enum
30
- enums do
31
- Generation = new('generation')
32
- Agent = new('agent')
33
- Tool = new('tool')
34
- Chain = new('chain')
35
- Retriever = new('retriever')
36
- Embedding = new('embedding')
37
- Evaluator = new('evaluator')
38
- Span = new('span')
39
- Event = new('event')
40
- end
29
+ # Guard against double-loading with Zeitwerk/Rails autoloader
30
+ # See: https://github.com/vicentereig/dspy.rb/issues/190
31
+ unless defined?(DSPy::ObservationType)
32
+ class ObservationType < T::Enum
33
+ enums do
34
+ Generation = new('generation')
35
+ Agent = new('agent')
36
+ Tool = new('tool')
37
+ Chain = new('chain')
38
+ Retriever = new('retriever')
39
+ Embedding = new('embedding')
40
+ Evaluator = new('evaluator')
41
+ Span = new('span')
42
+ Event = new('event')
43
+ end
41
44
 
42
- def self.for_module_class(_module_class)
43
- Span
44
- end
45
+ def self.for_module_class(_module_class)
46
+ Span
47
+ end
45
48
 
46
- def langfuse_attribute
47
- ['langfuse.observation.type', serialize]
48
- end
49
+ def langfuse_attribute
50
+ ['langfuse.observation.type', serialize]
51
+ end
49
52
 
50
- def langfuse_attributes
51
- { 'langfuse.observation.type' => serialize }
53
+ def langfuse_attributes
54
+ { 'langfuse.observation.type' => serialize }
55
+ end
52
56
  end
53
57
  end
54
58
  end
data/lib/dspy/prompt.rb CHANGED
@@ -155,17 +155,38 @@ module DSPy
155
155
  end
156
156
  end
157
157
 
158
- if data_format == :toon && @signature_class
158
+ if toon_data_format_enabled?
159
+ sections << "## TOON data format instructions"
160
+ sections << "All input and output payloads must use Token-Oriented Object Notation (TOON). Do not return JSON, YAML, or prose."
161
+ sections << ""
159
162
  sections << "## Input values"
163
+ sections << "Copy the TOON block below and replace the placeholder values with the correct inputs."
160
164
  sections << "```toon"
161
165
  sections << "{input_values}"
162
166
  sections << "```"
167
+
168
+ if (example_input = example_toon_payload(:input))
169
+ sections << ""
170
+ sections << "### Example TOON input"
171
+ sections << "```toon"
172
+ sections << example_input
173
+ sections << "```"
174
+ end
175
+
163
176
  sections << ""
164
177
  sections << "## Output values"
165
- sections << "Respond exclusively with a ```toon``` block containing only the output fields defined above, in the same order."
178
+ sections << "Respond exclusively with a ```toon``` block that lists the output fields in the exact order shown in the schema."
166
179
  sections << "```toon"
167
180
  sections << "{output_values}"
168
181
  sections << "```"
182
+
183
+ if (example_output = example_toon_payload(:output))
184
+ sections << ""
185
+ sections << "### Example TOON output"
186
+ sections << "```toon"
187
+ sections << example_output
188
+ sections << "```"
189
+ end
169
190
  else
170
191
  sections << "## Input values"
171
192
  sections << "```json"
@@ -189,15 +210,16 @@ module DSPy
189
210
  def render_user_prompt(input_values)
190
211
  sections = []
191
212
 
192
- if data_format == :toon && @signature_class
213
+ if toon_data_format_enabled?
193
214
  toon_payload = DSPy::Schema::SorbetToonAdapter.render_input(@signature_class, input_values)
194
215
 
195
216
  sections << "## Input Values"
217
+ sections << "Use the TOON block below as-is; do not convert it to JSON."
196
218
  sections << "```toon"
197
219
  sections << toon_payload
198
220
  sections << "```"
199
221
  sections << ""
200
- sections << "Respond with the corresponding output schema fields encoded as TOON inside a ```toon``` block starting with the heading `## Output values`."
222
+ sections << "Respond with the corresponding output schema fields encoded as TOON inside a ```toon``` block starting with the heading `## Output values`. Do not include any JSON."
201
223
  else
202
224
  sections << "## Input Values"
203
225
  sections << "```json"
@@ -393,5 +415,96 @@ module DSPy
393
415
 
394
416
  result
395
417
  end
418
+
419
+ def toon_data_format_enabled?
420
+ data_format == :toon && @signature_class
421
+ end
422
+
423
+ SAMPLE_DEPTH_LIMIT = 3
424
+ private_constant :SAMPLE_DEPTH_LIMIT
425
+
426
+ def example_toon_payload(role)
427
+ return nil unless toon_data_format_enabled?
428
+
429
+ sample_values = case role
430
+ when :input
431
+ sample_struct_values(@signature_class.input_struct_class)
432
+ when :output
433
+ sample_struct_values(@signature_class.output_struct_class)
434
+ else
435
+ {}
436
+ end
437
+
438
+ return nil if sample_values.empty?
439
+
440
+ case role
441
+ when :input
442
+ DSPy::Schema::SorbetToonAdapter.render_input(@signature_class, sample_values)
443
+ when :output
444
+ DSPy::Schema::SorbetToonAdapter.render_expected_output(@signature_class, sample_values)
445
+ end
446
+ rescue StandardError
447
+ nil
448
+ end
449
+
450
+ def sample_struct_values(struct_class, depth = 0)
451
+ return {} unless struct_class&.respond_to?(:props)
452
+ struct_class.props.each_with_object({}) do |(name, prop_info), memo|
453
+ memo[name] = sample_value_for_type(prop_info[:type], name, depth)
454
+ end
455
+ end
456
+
457
+ def sample_value_for_type(prop_type, field_name, depth)
458
+ return sample_string(field_name) if prop_type.nil? || depth > SAMPLE_DEPTH_LIMIT
459
+
460
+ case prop_type
461
+ when T::Types::Simple
462
+ sample_value_for_type(prop_type.raw_type, field_name, depth + 1)
463
+ when T::Types::Union
464
+ preferred = prop_type.types.find { |type| !nil_type?(type) } || prop_type.types.first
465
+ sample_value_for_type(preferred, field_name, depth + 1)
466
+ when T::Types::TypedArray
467
+ [sample_value_for_type(prop_type.type, field_name, depth + 1)]
468
+ when T::Types::TypedHash
469
+ key_sample = sample_value_for_type(prop_type.keys, "#{field_name}_key", depth + 1)
470
+ value_sample = sample_value_for_type(prop_type.values, "#{field_name}_value", depth + 1)
471
+ { key_sample.to_s => value_sample }
472
+ when Class
473
+ sample_for_class_type(prop_type, field_name, depth)
474
+ else
475
+ sample_string(field_name)
476
+ end
477
+ end
478
+
479
+ def sample_for_class_type(prop_type, field_name, depth)
480
+ if prop_type <= String
481
+ sample_string(field_name)
482
+ elsif prop_type <= Integer
483
+ 1
484
+ elsif prop_type <= Float
485
+ 1.0
486
+ elsif prop_type <= Numeric
487
+ 1
488
+ elsif prop_type <= TrueClass || prop_type <= FalseClass
489
+ true
490
+ elsif prop_type <= T::Enum
491
+ enum_value = prop_type.values.first
492
+ enum_value ? enum_value.serialize : sample_string(field_name)
493
+ elsif prop_type <= T::Struct
494
+ sample_struct_values(prop_type, depth + 1)
495
+ else
496
+ sample_string(field_name)
497
+ end
498
+ end
499
+
500
+ def nil_type?(type)
501
+ (type.respond_to?(:raw_type) && type.raw_type == NilClass) || type == NilClass
502
+ end
503
+
504
+ def sample_string(field_name)
505
+ base = field_name.to_s.gsub(/[^a-z0-9]+/i, '_').gsub(/_{2,}/, '_').sub(/^_+|_+$/, '')
506
+ base = 'value' if base.empty?
507
+ "example_#{base}"
508
+ end
396
509
  end
397
510
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dspy/lm/errors'
4
+
5
+ module DSPy
6
+ module RubyLLM
7
+ class Guardrails
8
+ SUPPORTED_RUBY_LLM_VERSIONS = "~> 1.3".freeze
9
+
10
+ def self.ensure_ruby_llm_installed!
11
+ require 'ruby_llm'
12
+
13
+ spec = Gem.loaded_specs["ruby_llm"]
14
+ unless spec && Gem::Requirement.new(SUPPORTED_RUBY_LLM_VERSIONS).satisfied_by?(spec.version)
15
+ msg = <<~MSG
16
+ DSPy requires the `ruby_llm` gem #{SUPPORTED_RUBY_LLM_VERSIONS}.
17
+ Please install or upgrade it with `bundle add ruby_llm --version "#{SUPPORTED_RUBY_LLM_VERSIONS}"`.
18
+ MSG
19
+ raise DSPy::LM::UnsupportedVersionError, msg
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'ruby_llm'
5
+ require 'dspy/lm/adapter'
6
+ require 'dspy/lm/vision_models'
7
+
8
+ require 'dspy/ruby_llm/guardrails'
9
+ DSPy::RubyLLM::Guardrails.ensure_ruby_llm_installed!
10
+
11
+ module DSPy
12
+ module RubyLLM
13
+ module LM
14
+ module Adapters
15
+ class RubyLLMAdapter < DSPy::LM::Adapter
16
+ attr_reader :provider
17
+
18
+ # Options that require a scoped context instead of global RubyLLM config
19
+ SCOPED_OPTIONS = %i[base_url timeout max_retries].freeze
20
+
21
+ def initialize(model:, api_key: nil, **options)
22
+ @api_key = api_key
23
+ @options = options
24
+ @structured_outputs_enabled = options.fetch(:structured_outputs, true)
25
+ @provider_override = options[:provider] # Optional provider override
26
+
27
+ # Detect provider eagerly (matches OpenAI/Anthropic/Gemini adapters)
28
+ @provider = detect_provider(model)
29
+
30
+ # Determine if we should use global RubyLLM config or create scoped context
31
+ @use_global_config = should_use_global_config?(api_key, options)
32
+
33
+ super(model: model, api_key: api_key)
34
+
35
+ # Only validate API key if not using global config
36
+ unless @use_global_config
37
+ validate_api_key_for_provider!(api_key)
38
+ end
39
+
40
+ # Validate base_url if provided
41
+ validate_base_url!(@options[:base_url])
42
+ end
43
+
44
+ # Returns the context - either scoped or global
45
+ def context
46
+ @context ||= @use_global_config ? nil : create_context(@api_key)
47
+ end
48
+
49
+ def chat(messages:, signature: nil, &block)
50
+ normalized_messages = normalize_messages(messages)
51
+
52
+ # Validate vision support if images are present
53
+ if contains_images?(normalized_messages)
54
+ validate_vision_support!
55
+ normalized_messages = format_multimodal_messages(normalized_messages)
56
+ end
57
+
58
+ chat_instance = create_chat_instance
59
+
60
+ if block_given?
61
+ stream_response(chat_instance, normalized_messages, signature, &block)
62
+ else
63
+ standard_response(chat_instance, normalized_messages, signature)
64
+ end
65
+ rescue ::RubyLLM::UnauthorizedError => e
66
+ raise DSPy::LM::MissingAPIKeyError.new(provider)
67
+ rescue ::RubyLLM::RateLimitError => e
68
+ raise DSPy::LM::AdapterError, "Rate limit exceeded for #{provider}: #{e.message}"
69
+ rescue ::RubyLLM::ModelNotFoundError => e
70
+ raise DSPy::LM::AdapterError, "Model not found: #{e.message}. Check available models with RubyLLM.models.all"
71
+ rescue ::RubyLLM::BadRequestError => e
72
+ raise DSPy::LM::AdapterError, "Invalid request to #{provider}: #{e.message}"
73
+ rescue ::RubyLLM::ConfigurationError => e
74
+ raise DSPy::LM::ConfigurationError, "RubyLLM configuration error: #{e.message}"
75
+ rescue ::RubyLLM::Error => e
76
+ raise DSPy::LM::AdapterError, "RubyLLM error (#{provider}): #{e.message}"
77
+ end
78
+
79
+ private
80
+
81
+ # Detect provider from RubyLLM's model registry or use explicit override
82
+ def detect_provider(model_id)
83
+ return @provider_override.to_s if @provider_override
84
+
85
+ model_info = ::RubyLLM.models.find(model_id)
86
+ model_info.provider.to_s
87
+ rescue ::RubyLLM::ModelNotFoundError
88
+ raise DSPy::LM::ConfigurationError,
89
+ "Model '#{model_id}' not found in RubyLLM registry. " \
90
+ "Use provider: option to specify explicitly, or run RubyLLM.models.refresh!"
91
+ end
92
+
93
+ # Check if we should use RubyLLM's global configuration
94
+ # Uses global config when no api_key and no provider-specific options provided
95
+ def should_use_global_config?(api_key, options)
96
+ api_key.nil? && (options.keys & SCOPED_OPTIONS).empty?
97
+ end
98
+
99
+ # Validate API key for providers that require it
100
+ def validate_api_key_for_provider!(api_key)
101
+ # Ollama and some local providers don't require API keys
102
+ return if provider_allows_no_api_key?
103
+
104
+ validate_api_key!(api_key, provider)
105
+ end
106
+
107
+ def provider_allows_no_api_key?
108
+ %w[ollama gpustack].include?(provider)
109
+ end
110
+
111
+ def validate_base_url!(url)
112
+ return if url.nil?
113
+
114
+ uri = URI.parse(url)
115
+ unless %w[http https].include?(uri.scheme)
116
+ raise DSPy::LM::ConfigurationError, "base_url must use http or https scheme"
117
+ end
118
+ rescue URI::InvalidURIError
119
+ raise DSPy::LM::ConfigurationError, "Invalid base_url format: #{url}"
120
+ end
121
+
122
+ def create_context(api_key)
123
+ ::RubyLLM.context do |config|
124
+ configure_provider(config, api_key)
125
+ configure_connection(config)
126
+ end
127
+ end
128
+
129
+ # Configure RubyLLM using convention: {provider}_api_key and {provider}_api_base
130
+ # For providers with non-standard auth (bedrock, vertexai), configure RubyLLM globally
131
+ def configure_provider(config, api_key)
132
+ key_method = "#{provider}_api_key="
133
+ config.send(key_method, api_key) if api_key && config.respond_to?(key_method)
134
+
135
+ base_method = "#{provider}_api_base="
136
+ config.send(base_method, @options[:base_url]) if @options[:base_url] && config.respond_to?(base_method)
137
+ end
138
+
139
+ def configure_connection(config)
140
+ config.request_timeout = @options[:timeout] if @options[:timeout]
141
+ config.max_retries = @options[:max_retries] if @options[:max_retries]
142
+ end
143
+
144
+ def create_chat_instance
145
+ chat_options = { model: model }
146
+
147
+ # If provider is explicitly overridden, pass it to RubyLLM
148
+ if @provider_override
149
+ chat_options[:provider] = @provider_override.to_sym
150
+ chat_options[:assume_model_exists] = true
151
+ end
152
+
153
+ # Use global RubyLLM config or scoped context
154
+ if @use_global_config
155
+ ::RubyLLM.chat(**chat_options)
156
+ else
157
+ context.chat(**chat_options)
158
+ end
159
+ end
160
+
161
+ def standard_response(chat_instance, messages, signature)
162
+ chat_instance = prepare_chat_instance(chat_instance, messages, signature)
163
+ content, attachments = prepare_message_content(messages)
164
+ return build_empty_response unless content
165
+
166
+ response = send_message(chat_instance, content, attachments)
167
+ map_response(response)
168
+ end
169
+
170
+ def stream_response(chat_instance, messages, signature, &block)
171
+ chat_instance = prepare_chat_instance(chat_instance, messages, signature)
172
+ content, attachments = prepare_message_content(messages)
173
+ return build_empty_response unless content
174
+
175
+ response = send_message(chat_instance, content, attachments, &block)
176
+ map_response(response)
177
+ end
178
+
179
+ # Common setup: apply system instructions, build conversation history, and optional schema
180
+ def prepare_chat_instance(chat_instance, messages, signature)
181
+ # First, handle system messages via with_instructions for proper system prompt handling
182
+ system_message = messages.find { |m| m[:role] == 'system' }
183
+ chat_instance = chat_instance.with_instructions(system_message[:content]) if system_message
184
+
185
+ # Build conversation history by adding all non-system messages except the last user message
186
+ # The last user message will be passed to ask() to get the response
187
+ messages_to_add = messages.reject { |m| m[:role] == 'system' }
188
+
189
+ # Find the index of the last user message
190
+ last_user_index = messages_to_add.rindex { |m| m[:role] == 'user' }
191
+
192
+ if last_user_index && last_user_index > 0
193
+ # Add all messages before the last user message to build history
194
+ messages_to_add[0...last_user_index].each do |msg|
195
+ content, attachments = extract_content_and_attachments(msg)
196
+ next unless content
197
+
198
+ # Add message with appropriate role
199
+ if attachments.any?
200
+ chat_instance.add_message(role: msg[:role].to_sym, content: content, attachments: attachments)
201
+ else
202
+ chat_instance.add_message(role: msg[:role].to_sym, content: content)
203
+ end
204
+ end
205
+ end
206
+
207
+ if signature && @structured_outputs_enabled
208
+ schema = build_json_schema(signature)
209
+ chat_instance = chat_instance.with_schema(schema) if schema
210
+ end
211
+
212
+ chat_instance
213
+ end
214
+
215
+ # Extract content from last user message
216
+ # RubyLLM's Chat API builds conversation history via add_message() for previous turns,
217
+ # and the last user message is passed to ask() to get the response.
218
+ def prepare_message_content(messages)
219
+ last_user_message = messages.reverse.find { |m| m[:role] == 'user' }
220
+ return [nil, []] unless last_user_message
221
+
222
+ extract_content_and_attachments(last_user_message)
223
+ end
224
+
225
+ # Send message with optional streaming block
226
+ def send_message(chat_instance, content, attachments, &block)
227
+ kwargs = attachments.any? ? { with: attachments } : {}
228
+
229
+ if block_given?
230
+ chat_instance.ask(content, **kwargs) do |chunk|
231
+ block.call(chunk.content) if chunk.content
232
+ end
233
+ else
234
+ chat_instance.ask(content, **kwargs)
235
+ end
236
+ end
237
+
238
+ def extract_content_and_attachments(message)
239
+ content = message[:content]
240
+ attachments = []
241
+
242
+ if content.is_a?(Array)
243
+ text_parts = []
244
+ content.each do |item|
245
+ case item[:type]
246
+ when 'text'
247
+ text_parts << item[:text]
248
+ when 'image'
249
+ # Extract image URL or path
250
+ image = item[:image]
251
+ if image.respond_to?(:url)
252
+ attachments << image.url
253
+ elsif image.respond_to?(:path)
254
+ attachments << image.path
255
+ elsif item[:image_url]
256
+ attachments << item[:image_url][:url]
257
+ end
258
+ end
259
+ end
260
+ content = text_parts.join("\n")
261
+ end
262
+
263
+ [content.to_s, attachments]
264
+ end
265
+
266
+ def map_response(ruby_llm_response)
267
+ DSPy::LM::Response.new(
268
+ content: ruby_llm_response.content.to_s,
269
+ usage: build_usage(ruby_llm_response),
270
+ metadata: build_metadata(ruby_llm_response)
271
+ )
272
+ end
273
+
274
+ def build_usage(response)
275
+ input_tokens = response.input_tokens || 0
276
+ output_tokens = response.output_tokens || 0
277
+
278
+ DSPy::LM::Usage.new(
279
+ input_tokens: input_tokens,
280
+ output_tokens: output_tokens,
281
+ total_tokens: input_tokens + output_tokens
282
+ )
283
+ end
284
+
285
+ def build_metadata(response)
286
+ DSPy::LM::ResponseMetadataFactory.create('ruby_llm', {
287
+ model: response.model_id || model,
288
+ underlying_provider: provider
289
+ })
290
+ end
291
+
292
+ def build_empty_response
293
+ DSPy::LM::Response.new(
294
+ content: '',
295
+ usage: DSPy::LM::Usage.new(input_tokens: 0, output_tokens: 0, total_tokens: 0),
296
+ metadata: DSPy::LM::ResponseMetadataFactory.create('ruby_llm', {
297
+ model: model,
298
+ underlying_provider: provider
299
+ })
300
+ )
301
+ end
302
+
303
+ def build_json_schema(signature)
304
+ return nil unless signature.respond_to?(:json_schema)
305
+
306
+ schema = signature.json_schema
307
+ normalize_schema(schema)
308
+ end
309
+
310
+ def normalize_schema(schema)
311
+ return schema unless schema.is_a?(Hash)
312
+
313
+ @normalized_schema_cache ||= {}
314
+ cache_key = schema.hash
315
+
316
+ @normalized_schema_cache[cache_key] ||= begin
317
+ duped = deep_dup(schema)
318
+ add_additional_properties_false(duped)
319
+ duped.freeze
320
+ end
321
+ end
322
+
323
+ def add_additional_properties_false(schema)
324
+ return unless schema.is_a?(Hash)
325
+
326
+ if schema[:type] == 'object' || schema['type'] == 'object'
327
+ schema[:additionalProperties] = false
328
+ schema['additionalProperties'] = false
329
+ end
330
+
331
+ # Recursively process nested schemas
332
+ schema.each_value { |v| add_additional_properties_false(v) if v.is_a?(Hash) }
333
+
334
+ # Handle arrays with items
335
+ if schema[:items]
336
+ add_additional_properties_false(schema[:items])
337
+ elsif schema['items']
338
+ add_additional_properties_false(schema['items'])
339
+ end
340
+ end
341
+
342
+ def deep_dup(obj)
343
+ case obj
344
+ when Hash
345
+ obj.transform_values { |v| deep_dup(v) }
346
+ when Array
347
+ obj.map { |v| deep_dup(v) }
348
+ else
349
+ obj
350
+ end
351
+ end
352
+
353
+ def validate_vision_support!
354
+ # RubyLLM handles vision validation internally, but we can add
355
+ # additional DSPy-specific validation here if needed
356
+ DSPy::LM::VisionModels.validate_vision_support!(provider, model)
357
+ rescue DSPy::LM::IncompatibleImageFeatureError
358
+ # If DSPy doesn't know about the model, let RubyLLM handle it
359
+ # RubyLLM has its own model registry with capability detection
360
+ end
361
+
362
+ def format_multimodal_messages(messages)
363
+ messages.map do |msg|
364
+ if msg[:content].is_a?(Array)
365
+ formatted_content = msg[:content].map do |item|
366
+ case item[:type]
367
+ when 'text'
368
+ { type: 'text', text: item[:text] }
369
+ when 'image'
370
+ # Validate and format image for provider
371
+ image = item[:image]
372
+ if image.respond_to?(:validate_for_provider!)
373
+ image.validate_for_provider!(provider)
374
+ end
375
+ item
376
+ else
377
+ item
378
+ end
379
+ end
380
+
381
+ { role: msg[:role], content: formatted_content }
382
+ else
383
+ msg
384
+ end
385
+ end
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module RubyLLM
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dspy/ruby_llm/version'
4
+
5
+ require 'dspy/ruby_llm/guardrails'
6
+ DSPy::RubyLLM::Guardrails.ensure_ruby_llm_installed!
7
+
8
+ require 'dspy/ruby_llm/lm/adapters/ruby_llm_adapter'
@@ -37,7 +37,8 @@ module DSPy
37
37
  Sorbet::Toon.decode(
38
38
  payload,
39
39
  signature: signature_class,
40
- role: :output
40
+ role: :output,
41
+ strict: false
41
42
  )
42
43
  rescue Sorbet::Toon::DecodeError => e
43
44
  log_decode_error(payload, e)
@@ -66,6 +66,8 @@ module DSPy
66
66
  tool :get_issue, description: "Get details of a specific GitHub issue"
67
67
  tool :get_pr, description: "Get details of a specific GitHub pull request"
68
68
  tool :api_request, description: "Make an arbitrary GitHub API request"
69
+ tool :traffic_views, description: "Get repository traffic views (last 14 days by default)"
70
+ tool :traffic_clones, description: "Get repository traffic clones (last 14 days by default)"
69
71
 
70
72
  sig { void }
71
73
  def initialize
@@ -216,6 +218,40 @@ module DSPy
216
218
  "Error making API request: #{e.message}"
217
219
  end
218
220
 
221
+ sig { params(repo: String, per: T.nilable(String)).returns(String) }
222
+ def traffic_views(repo:, per: nil)
223
+ endpoint = "repos/#{repo}/traffic/views"
224
+ cmd = build_gh_command(['api', shell_escape(endpoint)])
225
+ cmd << ['-f', "per=#{shell_escape(per)}"] if per
226
+
227
+ result = execute_command(cmd.flatten.join(' '))
228
+
229
+ if result[:success]
230
+ parse_traffic(result[:output], label: 'Views')
231
+ else
232
+ "Failed to fetch traffic views: #{result[:error]}"
233
+ end
234
+ rescue => e
235
+ "Error fetching traffic views: #{e.message}"
236
+ end
237
+
238
+ sig { params(repo: String, per: T.nilable(String)).returns(String) }
239
+ def traffic_clones(repo:, per: nil)
240
+ endpoint = "repos/#{repo}/traffic/clones"
241
+ cmd = build_gh_command(['api', shell_escape(endpoint)])
242
+ cmd << ['-f', "per=#{shell_escape(per)}"] if per
243
+
244
+ result = execute_command(cmd.flatten.join(' '))
245
+
246
+ if result[:success]
247
+ parse_traffic(result[:output], label: 'Clones')
248
+ else
249
+ "Failed to fetch traffic clones: #{result[:error]}"
250
+ end
251
+ rescue => e
252
+ "Error fetching traffic clones: #{e.message}"
253
+ end
254
+
219
255
  private
220
256
 
221
257
  sig { params(args: T::Array[String]).returns(T::Array[String]) }
@@ -225,6 +261,7 @@ module DSPy
225
261
 
226
262
  sig { params(str: String).returns(String) }
227
263
  def shell_escape(str)
264
+ return '""' if str.nil?
228
265
  "\"#{str.gsub(/"/, '\\"')}\""
229
266
  end
230
267
 
@@ -240,6 +277,29 @@ module DSPy
240
277
  }
241
278
  end
242
279
 
280
+ sig { params(json_output: String, label: String).returns(String) }
281
+ def parse_traffic(json_output, label:)
282
+ data = JSON.parse(json_output)
283
+
284
+ total = data['count'] || 0
285
+ uniques = data['uniques'] || 0
286
+ series = data[label.downcase] || data['views'] || []
287
+
288
+ lines = []
289
+ lines << "#{label}: #{total} total (#{uniques} unique) over the last #{series.length} data points"
290
+
291
+ series.each do |point|
292
+ ts = point['timestamp'] || point['timestamp'.to_sym]
293
+ count = point['count'] || 0
294
+ uniq = point['uniques'] || 0
295
+ lines << " #{ts}: #{count} (#{uniq} unique)"
296
+ end
297
+
298
+ lines.join("\n")
299
+ rescue JSON::ParserError => e
300
+ "Failed to parse traffic data: #{e.message}"
301
+ end
302
+
243
303
  sig { params(json_output: String).returns(String) }
244
304
  def parse_issue_list(json_output)
245
305
  issues = JSON.parse(json_output)
@@ -327,4 +387,4 @@ module DSPy
327
387
  end
328
388
  end
329
389
  end
330
- end
390
+ end
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.31.0"
4
+ VERSION = "0.32.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.0
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
@@ -210,6 +210,10 @@ files:
210
210
  - lib/dspy/reflection_lm.rb
211
211
  - lib/dspy/registry/registry_manager.rb
212
212
  - lib/dspy/registry/signature_registry.rb
213
+ - lib/dspy/ruby_llm.rb
214
+ - lib/dspy/ruby_llm/guardrails.rb
215
+ - lib/dspy/ruby_llm/lm/adapters/ruby_llm_adapter.rb
216
+ - lib/dspy/ruby_llm/version.rb
213
217
  - lib/dspy/schema.rb
214
218
  - lib/dspy/schema/sorbet_json_schema.rb
215
219
  - lib/dspy/schema/sorbet_toon_adapter.rb