dspy 0.34.4 → 1.0.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 +4 -4
- data/README.md +1 -0
- data/lib/dspy/document.rb +153 -0
- data/lib/dspy/lm/adapter.rb +23 -0
- data/lib/dspy/lm/errors.rb +7 -2
- data/lib/dspy/lm/json_strategy.rb +67 -30
- data/lib/dspy/lm/message.rb +5 -1
- data/lib/dspy/lm/message_builder.rb +15 -1
- data/lib/dspy/lm.rb +68 -6
- data/lib/dspy/ruby_llm/lm/adapters/ruby_llm_adapter.rb +13 -3
- data/lib/dspy/ruby_llm/version.rb +1 -1
- data/lib/dspy/ruby_llm.rb +0 -3
- data/lib/dspy/support/openai_sdk_warning.rb +32 -0
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +3 -16
- metadata +3 -2
- data/lib/dspy/ruby_llm/guardrails.rb +0 -24
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85ae2299dbddc20bfd710c68e9e8dfbeeb201b742e3fd18b768669ddc4443261
|
|
4
|
+
data.tar.gz: ba0ee2d5f637f499448e456fb58f0513aa58b5b258e754bf1b32839ea8c49b63
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 310de5ce11b23d29bd168069eae4f5ce3ac154ba91974c96b9d70e5d02a0dcb45122dc4c83f80097a409ff448a0a30d45ebec3ff0883d80b2cdd8f1215d2dfff
|
|
7
|
+
data.tar.gz: 1ce5ff1bfe900bcedabab28efba1e8352fed81c6cbf458d5b7c54e8f7eea29fa6b7ee33a62dcc73b456ede6181a85bbf055541e4fb70037ac4de05e3be2b8ce9
|
data/README.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
**Build reliable LLM applications in idiomatic Ruby using composable, type-safe modules.**
|
|
10
10
|
|
|
11
11
|
DSPy.rb is the Ruby port of Stanford's [DSPy](https://dspy.ai). Instead of wrestling with brittle prompt strings, you define typed signatures and let the framework handle the rest. Prompts become functions. LLM calls become predictable.
|
|
12
|
+
The `1.x` line is the stable release track for production Ruby LLM applications.
|
|
12
13
|
|
|
13
14
|
```ruby
|
|
14
15
|
require 'dspy'
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module DSPy
|
|
8
|
+
class Document
|
|
9
|
+
class RubyLLMInlineAttachment < StringIO
|
|
10
|
+
attr_reader :path
|
|
11
|
+
|
|
12
|
+
def initialize(content, path:)
|
|
13
|
+
super(content)
|
|
14
|
+
@path = path
|
|
15
|
+
binmode
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private_constant :RubyLLMInlineAttachment
|
|
20
|
+
|
|
21
|
+
attr_reader :url, :base64, :data, :content_type
|
|
22
|
+
|
|
23
|
+
SUPPORTED_FORMATS = %w[application/pdf].freeze
|
|
24
|
+
MAX_SIZE_BYTES = 32 * 1024 * 1024 # 32MB limit
|
|
25
|
+
|
|
26
|
+
def initialize(url: nil, base64: nil, data: nil, content_type: nil)
|
|
27
|
+
validate_input!(url, base64, data)
|
|
28
|
+
|
|
29
|
+
if url
|
|
30
|
+
@url = url
|
|
31
|
+
@content_type = content_type || infer_content_type_from_url(url)
|
|
32
|
+
elsif base64
|
|
33
|
+
raise ArgumentError, "content_type is required when using base64" unless content_type
|
|
34
|
+
|
|
35
|
+
@base64 = base64
|
|
36
|
+
@content_type = content_type
|
|
37
|
+
validate_size!(Base64.decode64(base64).bytesize)
|
|
38
|
+
elsif data
|
|
39
|
+
raise ArgumentError, "content_type is required when using data" unless content_type
|
|
40
|
+
|
|
41
|
+
@data = data
|
|
42
|
+
@content_type = content_type
|
|
43
|
+
validate_size!(data.size)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
validate_content_type!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_openai_format
|
|
50
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
51
|
+
"OpenAI document inputs are not supported in this release. Use Anthropic directly or Anthropic via RubyLLM."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_anthropic_format
|
|
55
|
+
if url
|
|
56
|
+
{
|
|
57
|
+
type: 'document',
|
|
58
|
+
source: {
|
|
59
|
+
type: 'url',
|
|
60
|
+
url: url
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
{
|
|
65
|
+
type: 'document',
|
|
66
|
+
source: {
|
|
67
|
+
type: 'base64',
|
|
68
|
+
media_type: content_type,
|
|
69
|
+
data: to_base64
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_gemini_format
|
|
76
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
77
|
+
"Gemini document inputs are not supported in this release. Use Anthropic directly or Anthropic via RubyLLM."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_ruby_llm_attachment
|
|
81
|
+
if url
|
|
82
|
+
url
|
|
83
|
+
else
|
|
84
|
+
RubyLLMInlineAttachment.new(to_binary, path: 'document.pdf')
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_base64
|
|
89
|
+
return base64 if base64
|
|
90
|
+
return Base64.strict_encode64(data.pack('C*')) if data
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_for_provider!(provider)
|
|
96
|
+
case provider
|
|
97
|
+
when 'anthropic'
|
|
98
|
+
true
|
|
99
|
+
when 'openai'
|
|
100
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
101
|
+
"OpenAI document inputs are not supported in this release. Use Anthropic directly or Anthropic via RubyLLM."
|
|
102
|
+
when 'gemini'
|
|
103
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
104
|
+
"Gemini document inputs are not supported in this release. Use Anthropic directly or Anthropic via RubyLLM."
|
|
105
|
+
else
|
|
106
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
107
|
+
"Unknown provider '#{provider}'. Document inputs are currently supported only for Anthropic."
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def validate_input!(url, base64, data)
|
|
114
|
+
inputs = [url, base64, data].compact
|
|
115
|
+
|
|
116
|
+
if inputs.empty?
|
|
117
|
+
raise ArgumentError, "Must provide either url, base64, or data"
|
|
118
|
+
elsif inputs.size > 1
|
|
119
|
+
raise ArgumentError, "Only one of url, base64, or data can be provided"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_content_type!
|
|
124
|
+
unless SUPPORTED_FORMATS.include?(content_type)
|
|
125
|
+
raise ArgumentError, "Unsupported document format: #{content_type}. Supported formats: #{SUPPORTED_FORMATS.join(', ')}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_size!(size_bytes)
|
|
130
|
+
if size_bytes > MAX_SIZE_BYTES
|
|
131
|
+
raise ArgumentError, "Document size exceeds 32MB limit (got #{size_bytes} bytes)"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def infer_content_type_from_url(url)
|
|
136
|
+
extension = File.extname(URI.parse(url).path).downcase
|
|
137
|
+
|
|
138
|
+
case extension
|
|
139
|
+
when '.pdf'
|
|
140
|
+
'application/pdf'
|
|
141
|
+
else
|
|
142
|
+
raise ArgumentError, "Document URL must point to a PDF (.pdf): #{url}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def to_binary
|
|
147
|
+
return Base64.decode64(base64) if base64
|
|
148
|
+
return data.pack('C*') if data
|
|
149
|
+
|
|
150
|
+
raise ArgumentError, "Document has no binary content"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/dspy/lm/adapter.rb
CHANGED
|
@@ -58,6 +58,17 @@ module DSPy
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
def contains_documents?(messages)
|
|
62
|
+
messages.any? do |msg|
|
|
63
|
+
content = msg[:content] || msg.content
|
|
64
|
+
content.is_a?(Array) && content.any? { |item| item[:type] == 'document' }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def contains_media?(messages)
|
|
69
|
+
contains_images?(messages) || contains_documents?(messages)
|
|
70
|
+
end
|
|
71
|
+
|
|
61
72
|
# Format multimodal messages for a specific provider
|
|
62
73
|
# @param messages [Array<Hash>] Array of message hashes
|
|
63
74
|
# @param provider_name [String] Provider name for image validation and formatting
|
|
@@ -71,6 +82,8 @@ module DSPy
|
|
|
71
82
|
{ type: 'text', text: item[:text] }
|
|
72
83
|
when 'image'
|
|
73
84
|
format_image_for_provider(item[:image], provider_name)
|
|
85
|
+
when 'document'
|
|
86
|
+
format_document_for_provider(item[:document], provider_name)
|
|
74
87
|
else
|
|
75
88
|
item
|
|
76
89
|
end
|
|
@@ -96,6 +109,16 @@ module DSPy
|
|
|
96
109
|
{ type: 'image', image: image }
|
|
97
110
|
end
|
|
98
111
|
end
|
|
112
|
+
|
|
113
|
+
def format_document_for_provider(document, provider_name)
|
|
114
|
+
document.validate_for_provider!(provider_name)
|
|
115
|
+
format_method = "to_#{provider_name}_format"
|
|
116
|
+
if document.respond_to?(format_method)
|
|
117
|
+
document.send(format_method)
|
|
118
|
+
else
|
|
119
|
+
{ type: 'document', document: document }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
99
122
|
end
|
|
100
123
|
end
|
|
101
124
|
end
|
data/lib/dspy/lm/errors.rb
CHANGED
|
@@ -7,8 +7,6 @@ module DSPy
|
|
|
7
7
|
class UnsupportedProviderError < Error; end
|
|
8
8
|
class ConfigurationError < Error; end
|
|
9
9
|
class MissingAdapterError < Error; end
|
|
10
|
-
class UnsupportedVersionError < Error; end
|
|
11
|
-
class MissingOfficialSDKError < Error; end
|
|
12
10
|
|
|
13
11
|
# Raised when API key is missing or invalid
|
|
14
12
|
class MissingAPIKeyError < Error
|
|
@@ -29,5 +27,12 @@ module DSPy
|
|
|
29
27
|
super(message)
|
|
30
28
|
end
|
|
31
29
|
end
|
|
30
|
+
|
|
31
|
+
# Raised when document features are incompatible with the target provider
|
|
32
|
+
class IncompatibleDocumentFeatureError < AdapterError
|
|
33
|
+
def initialize(message)
|
|
34
|
+
super(message)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
32
37
|
end
|
|
33
38
|
end
|
|
@@ -136,59 +136,96 @@ module DSPy
|
|
|
136
136
|
def extract_json_from_content(content)
|
|
137
137
|
return content if content.nil? || content.empty?
|
|
138
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
|
-
|
|
147
139
|
# Try 1: Check for ```json code block (with or without preceding text)
|
|
148
140
|
if content.include?('```json')
|
|
149
141
|
json_match = content.match(/```json\s*\n(.*?)\n```/m)
|
|
150
|
-
|
|
142
|
+
if json_match
|
|
143
|
+
normalized = normalize_json_candidate(json_match[1].strip)
|
|
144
|
+
return normalized if valid_json?(normalized)
|
|
145
|
+
end
|
|
151
146
|
end
|
|
152
147
|
|
|
153
148
|
# Try 2: Check for generic ``` code block
|
|
154
149
|
if content.include?('```')
|
|
155
150
|
code_match = content.match(/```\s*\n(.*?)\n```/m)
|
|
156
151
|
if code_match
|
|
157
|
-
potential_json = code_match[1].strip
|
|
158
|
-
|
|
159
|
-
begin
|
|
160
|
-
JSON.parse(potential_json)
|
|
161
|
-
return potential_json
|
|
162
|
-
rescue JSON::ParserError
|
|
163
|
-
# Not valid JSON, continue
|
|
164
|
-
end
|
|
152
|
+
potential_json = normalize_json_candidate(code_match[1].strip)
|
|
153
|
+
return potential_json if valid_json?(potential_json)
|
|
165
154
|
end
|
|
166
155
|
end
|
|
167
156
|
|
|
168
157
|
# Try 3: Try parsing entire content as JSON
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return content
|
|
172
|
-
rescue JSON::ParserError
|
|
173
|
-
# Not pure JSON, try extracting
|
|
174
|
-
end
|
|
158
|
+
normalized_content = normalize_json_candidate(content)
|
|
159
|
+
return normalized_content if valid_json?(normalized_content)
|
|
175
160
|
|
|
176
161
|
# Try 4: Look for JSON object pattern in text (greedy match for nested objects)
|
|
177
162
|
json_pattern = /\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}/m
|
|
178
163
|
json_match = content.match(json_pattern)
|
|
179
164
|
if json_match
|
|
180
|
-
potential_json = json_match[0]
|
|
181
|
-
|
|
182
|
-
JSON.parse(potential_json)
|
|
183
|
-
return potential_json
|
|
184
|
-
rescue JSON::ParserError
|
|
185
|
-
# Not valid JSON
|
|
186
|
-
end
|
|
165
|
+
potential_json = normalize_json_candidate(json_match[0])
|
|
166
|
+
return potential_json if valid_json?(potential_json)
|
|
187
167
|
end
|
|
188
168
|
|
|
189
169
|
# Return content as-is if no JSON found
|
|
190
170
|
content
|
|
191
171
|
end
|
|
172
|
+
|
|
173
|
+
sig { params(content: String).returns(String) }
|
|
174
|
+
def normalize_json_candidate(content)
|
|
175
|
+
escape_control_characters_in_strings(remove_trailing_object_commas(content))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
sig { params(content: String).returns(String) }
|
|
179
|
+
def remove_trailing_object_commas(content)
|
|
180
|
+
content.sub(/,(\s*\}\s*)$/, '\1')
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
sig { params(content: String).returns(T::Boolean) }
|
|
184
|
+
def valid_json?(content)
|
|
185
|
+
JSON.parse(content)
|
|
186
|
+
true
|
|
187
|
+
rescue JSON::ParserError
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
sig { params(content: String).returns(String) }
|
|
192
|
+
def escape_control_characters_in_strings(content)
|
|
193
|
+
escaped = +""
|
|
194
|
+
in_string = false
|
|
195
|
+
escaping = false
|
|
196
|
+
|
|
197
|
+
content.each_char do |char|
|
|
198
|
+
if in_string
|
|
199
|
+
if escaping
|
|
200
|
+
escaped << char
|
|
201
|
+
escaping = false
|
|
202
|
+
next
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
case char
|
|
206
|
+
when '\\'
|
|
207
|
+
escaped << char
|
|
208
|
+
escaping = true
|
|
209
|
+
when '"'
|
|
210
|
+
escaped << char
|
|
211
|
+
in_string = false
|
|
212
|
+
when "\n"
|
|
213
|
+
escaped << '\n'
|
|
214
|
+
when "\r"
|
|
215
|
+
escaped << '\r'
|
|
216
|
+
when "\t"
|
|
217
|
+
escaped << '\t'
|
|
218
|
+
else
|
|
219
|
+
escaped << (char.ord < 0x20 ? "" : char)
|
|
220
|
+
end
|
|
221
|
+
else
|
|
222
|
+
escaped << char
|
|
223
|
+
in_string = true if char == '"'
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
escaped
|
|
228
|
+
end
|
|
192
229
|
end
|
|
193
230
|
end
|
|
194
231
|
end
|
data/lib/dspy/lm/message.rb
CHANGED
|
@@ -59,6 +59,8 @@ module DSPy
|
|
|
59
59
|
{ type: 'text', text: item[:text] }
|
|
60
60
|
when 'image'
|
|
61
61
|
item[:image].to_openai_format
|
|
62
|
+
when 'document'
|
|
63
|
+
item[:document].to_openai_format
|
|
62
64
|
else
|
|
63
65
|
item
|
|
64
66
|
end
|
|
@@ -83,6 +85,8 @@ module DSPy
|
|
|
83
85
|
{ type: 'text', text: item[:text] }
|
|
84
86
|
when 'image'
|
|
85
87
|
item[:image].to_anthropic_format
|
|
88
|
+
when 'document'
|
|
89
|
+
item[:document].to_anthropic_format
|
|
86
90
|
else
|
|
87
91
|
item
|
|
88
92
|
end
|
|
@@ -160,4 +164,4 @@ module DSPy
|
|
|
160
164
|
end
|
|
161
165
|
end
|
|
162
166
|
end
|
|
163
|
-
end
|
|
167
|
+
end
|
|
@@ -68,6 +68,20 @@ module DSPy
|
|
|
68
68
|
)
|
|
69
69
|
self
|
|
70
70
|
end
|
|
71
|
+
|
|
72
|
+
sig { params(text: String, document: DSPy::Document).returns(MessageBuilder) }
|
|
73
|
+
def user_with_document(text, document)
|
|
74
|
+
content_array = [
|
|
75
|
+
{ type: 'text', text: text },
|
|
76
|
+
{ type: 'document', document: document }
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
@messages << Message.new(
|
|
80
|
+
role: Message::Role::User,
|
|
81
|
+
content: content_array
|
|
82
|
+
)
|
|
83
|
+
self
|
|
84
|
+
end
|
|
71
85
|
|
|
72
86
|
# For backward compatibility, allow conversion to hash array
|
|
73
87
|
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
|
|
@@ -76,4 +90,4 @@ module DSPy
|
|
|
76
90
|
end
|
|
77
91
|
end
|
|
78
92
|
end
|
|
79
|
-
end
|
|
93
|
+
end
|
data/lib/dspy/lm.rb
CHANGED
|
@@ -161,16 +161,78 @@ module DSPy
|
|
|
161
161
|
)
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
164
|
+
document_inputs = extract_document_inputs(input_values)
|
|
165
|
+
|
|
166
|
+
if document_inputs.empty?
|
|
167
|
+
user_prompt = prompt.render_user_prompt(input_values)
|
|
168
|
+
messages << Message.new(
|
|
169
|
+
role: Message::Role::User,
|
|
170
|
+
content: user_prompt
|
|
171
|
+
)
|
|
172
|
+
else
|
|
173
|
+
validate_document_predict_support!(input_values, document_inputs)
|
|
174
|
+
|
|
175
|
+
placeholder_inputs = input_values.transform_values do |value|
|
|
176
|
+
value.is_a?(DSPy::Document) ? "[attached pdf document]" : value
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
user_prompt = prompt.render_user_prompt(placeholder_inputs)
|
|
180
|
+
content_array = [
|
|
181
|
+
{ type: 'text', text: user_prompt },
|
|
182
|
+
{ type: 'document', document: document_inputs.first.last }
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
messages << Message.new(
|
|
186
|
+
role: Message::Role::User,
|
|
187
|
+
content: content_array
|
|
188
|
+
)
|
|
189
|
+
end
|
|
170
190
|
|
|
171
191
|
messages
|
|
172
192
|
end
|
|
173
193
|
|
|
194
|
+
def extract_document_inputs(input_values)
|
|
195
|
+
input_values.each_with_object([]) do |(key, value), inputs|
|
|
196
|
+
if value.is_a?(DSPy::Document)
|
|
197
|
+
inputs << [key, value]
|
|
198
|
+
elsif nested_document_input?(value)
|
|
199
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
200
|
+
"Only one top-level DSPy::Document input is currently supported in Predict."
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def nested_document_input?(value)
|
|
206
|
+
case value
|
|
207
|
+
when T::Struct
|
|
208
|
+
value.class.props.any? { |name, _| nested_document_input?(value.public_send(name)) }
|
|
209
|
+
when Array
|
|
210
|
+
value.any? { |item| nested_document_input?(item) }
|
|
211
|
+
when Hash
|
|
212
|
+
value.values.any? { |item| nested_document_input?(item) }
|
|
213
|
+
else
|
|
214
|
+
value.is_a?(DSPy::Document)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def validate_document_predict_support!(input_values, document_inputs)
|
|
219
|
+
if document_inputs.length > 1
|
|
220
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
221
|
+
"Only one top-level DSPy::Document input is currently supported in Predict."
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
if input_values.values.any? { |value| value.is_a?(DSPy::Image) }
|
|
225
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
226
|
+
"Predict does not support mixing DSPy::Document and DSPy::Image inputs in this release."
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
return if provider == 'anthropic'
|
|
230
|
+
return if adapter.class.name.include?('RubyLLMAdapter') && adapter.provider == 'anthropic'
|
|
231
|
+
|
|
232
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
233
|
+
"Document inputs are currently supported only for Anthropic models and Anthropic via RubyLLM."
|
|
234
|
+
end
|
|
235
|
+
|
|
174
236
|
def will_use_structured_outputs?(signature_class, data_format: nil)
|
|
175
237
|
return false unless signature_class
|
|
176
238
|
return false if data_format == :toon
|
|
@@ -5,9 +5,6 @@ require 'ruby_llm'
|
|
|
5
5
|
require 'dspy/lm/adapter'
|
|
6
6
|
require 'dspy/lm/vision_models'
|
|
7
7
|
|
|
8
|
-
require 'dspy/ruby_llm/guardrails'
|
|
9
|
-
DSPy::RubyLLM::Guardrails.ensure_ruby_llm_installed!
|
|
10
|
-
|
|
11
8
|
module DSPy
|
|
12
9
|
module RubyLLM
|
|
13
10
|
module LM
|
|
@@ -49,6 +46,8 @@ module DSPy
|
|
|
49
46
|
def chat(messages:, signature: nil, &block)
|
|
50
47
|
normalized_messages = normalize_messages(messages)
|
|
51
48
|
|
|
49
|
+
validate_document_support!(normalized_messages)
|
|
50
|
+
|
|
52
51
|
# Validate vision support if images are present
|
|
53
52
|
if contains_images?(normalized_messages)
|
|
54
53
|
validate_vision_support!
|
|
@@ -255,6 +254,9 @@ module DSPy
|
|
|
255
254
|
elsif item[:image_url]
|
|
256
255
|
attachments << item[:image_url][:url]
|
|
257
256
|
end
|
|
257
|
+
when 'document'
|
|
258
|
+
document = item[:document]
|
|
259
|
+
attachments << document.to_ruby_llm_attachment if document
|
|
258
260
|
end
|
|
259
261
|
end
|
|
260
262
|
content = text_parts.join("\n")
|
|
@@ -263,6 +265,14 @@ module DSPy
|
|
|
263
265
|
[content.to_s, attachments]
|
|
264
266
|
end
|
|
265
267
|
|
|
268
|
+
def validate_document_support!(messages)
|
|
269
|
+
return unless contains_documents?(messages)
|
|
270
|
+
return if provider == 'anthropic'
|
|
271
|
+
|
|
272
|
+
raise DSPy::LM::IncompatibleDocumentFeatureError,
|
|
273
|
+
"RubyLLM document inputs are currently supported only when the underlying provider is Anthropic."
|
|
274
|
+
end
|
|
275
|
+
|
|
266
276
|
def map_response(ruby_llm_response)
|
|
267
277
|
DSPy::LM::Response.new(
|
|
268
278
|
content: ruby_llm_response.content.to_s,
|
data/lib/dspy/ruby_llm.rb
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSPy
|
|
4
|
+
module Support
|
|
5
|
+
module OpenAISDKWarning
|
|
6
|
+
WARNING_MESSAGE = <<~WARNING.freeze
|
|
7
|
+
WARNING: ruby-openai gem detected. This may cause conflicts with DSPy's OpenAI integration.
|
|
8
|
+
|
|
9
|
+
DSPy uses the official 'openai' gem. The community 'ruby-openai' gem uses the same
|
|
10
|
+
OpenAI namespace and will cause conflicts.
|
|
11
|
+
|
|
12
|
+
To fix this, remove 'ruby-openai' from your Gemfile and use the official gem instead:
|
|
13
|
+
- Remove: gem 'ruby-openai'
|
|
14
|
+
- Keep: gem 'openai' (official SDK that DSPy uses)
|
|
15
|
+
|
|
16
|
+
The official gem provides better compatibility and is actively maintained by OpenAI.
|
|
17
|
+
WARNING
|
|
18
|
+
|
|
19
|
+
def self.warn_if_community_gem_loaded!
|
|
20
|
+
return if @warned
|
|
21
|
+
return unless community_gem_loaded?
|
|
22
|
+
|
|
23
|
+
Kernel.warn WARNING_MESSAGE
|
|
24
|
+
@warned = true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.community_gem_loaded?
|
|
28
|
+
defined?(::OpenAI) && defined?(::OpenAI::Client) && !defined?(::OpenAI::Internal)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/dspy/version.rb
CHANGED
data/lib/dspy.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require_relative 'dspy/support/warning_filters'
|
|
3
|
+
require_relative 'dspy/support/openai_sdk_warning'
|
|
3
4
|
require 'sorbet-runtime'
|
|
4
5
|
require 'dry-configurable'
|
|
5
6
|
require 'dry/logger'
|
|
@@ -267,6 +268,7 @@ require_relative 'dspy/prompt'
|
|
|
267
268
|
require_relative 'dspy/example'
|
|
268
269
|
require_relative 'dspy/lm'
|
|
269
270
|
require_relative 'dspy/image'
|
|
271
|
+
require_relative 'dspy/document'
|
|
270
272
|
require_relative 'dspy/prediction'
|
|
271
273
|
require_relative 'dspy/predict'
|
|
272
274
|
require_relative 'dspy/chain_of_thought'
|
|
@@ -309,19 +311,4 @@ DSPy::Observability.configure!
|
|
|
309
311
|
|
|
310
312
|
# LoggerSubscriber will be lazy-initialized when first accessed
|
|
311
313
|
|
|
312
|
-
|
|
313
|
-
# DSPy uses the official openai gem, warn if ruby-openai (community version) is detected
|
|
314
|
-
if defined?(OpenAI) && defined?(OpenAI::Client) && !defined?(OpenAI::Internal)
|
|
315
|
-
warn <<~WARNING
|
|
316
|
-
WARNING: ruby-openai gem detected. This may cause conflicts with DSPy's OpenAI integration.
|
|
317
|
-
|
|
318
|
-
DSPy uses the official 'openai' gem. The community 'ruby-openai' gem uses the same
|
|
319
|
-
OpenAI namespace and will cause conflicts.
|
|
320
|
-
|
|
321
|
-
To fix this, remove 'ruby-openai' from your Gemfile and use the official gem instead:
|
|
322
|
-
- Remove: gem 'ruby-openai'
|
|
323
|
-
- Keep: gem 'openai' (official SDK that DSPy uses)
|
|
324
|
-
|
|
325
|
-
The official gem provides better compatibility and is actively maintained by OpenAI.
|
|
326
|
-
WARNING
|
|
327
|
-
end
|
|
314
|
+
DSPy::Support::OpenAISDKWarning.warn_if_community_gem_loaded!
|
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.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vicente Reig Rincón de Arellano
|
|
@@ -157,6 +157,7 @@ files:
|
|
|
157
157
|
- lib/dspy/callbacks.rb
|
|
158
158
|
- lib/dspy/chain_of_thought.rb
|
|
159
159
|
- lib/dspy/context.rb
|
|
160
|
+
- lib/dspy/document.rb
|
|
160
161
|
- lib/dspy/error_formatter.rb
|
|
161
162
|
- lib/dspy/errors.rb
|
|
162
163
|
- lib/dspy/evals.rb
|
|
@@ -194,7 +195,6 @@ files:
|
|
|
194
195
|
- lib/dspy/registry/registry_manager.rb
|
|
195
196
|
- lib/dspy/registry/signature_registry.rb
|
|
196
197
|
- lib/dspy/ruby_llm.rb
|
|
197
|
-
- lib/dspy/ruby_llm/guardrails.rb
|
|
198
198
|
- lib/dspy/ruby_llm/lm/adapters/ruby_llm_adapter.rb
|
|
199
199
|
- lib/dspy/ruby_llm/version.rb
|
|
200
200
|
- lib/dspy/schema.rb
|
|
@@ -210,6 +210,7 @@ files:
|
|
|
210
210
|
- lib/dspy/storage/program_storage.rb
|
|
211
211
|
- lib/dspy/storage/storage_manager.rb
|
|
212
212
|
- lib/dspy/structured_outputs_prompt.rb
|
|
213
|
+
- lib/dspy/support/openai_sdk_warning.rb
|
|
213
214
|
- lib/dspy/support/warning_filters.rb
|
|
214
215
|
- lib/dspy/teleprompt/bootstrap_strategy.rb
|
|
215
216
|
- lib/dspy/teleprompt/data_handler.rb
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|