prescient 0.3.0 → 0.5.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/CHANGELOG.md +34 -0
- data/INTEGRATION_GUIDE.md +12 -1
- data/README.md +263 -14
- data/Rakefile +1 -1
- data/VECTOR_SEARCH_GUIDE.md +7 -3
- data/examples/README.md +2 -1
- data/examples/basic_usage.rb +1 -1
- data/examples/custom_contexts.rb +6 -21
- data/examples/vector_search.rb +69 -305
- data/exe/prescient +7 -0
- data/lib/prescient/cli.rb +479 -0
- data/lib/prescient/client.rb +16 -4
- data/lib/prescient/configuration_loader.rb +437 -0
- data/lib/prescient/provider/anthropic.rb +2 -2
- data/lib/prescient/provider/deepseek.rb +139 -0
- data/lib/prescient/provider/gemini.rb +173 -0
- data/lib/prescient/provider/huggingface.rb +8 -6
- data/lib/prescient/provider/mistral.rb +171 -0
- data/lib/prescient/provider/ollama.rb +3 -3
- data/lib/prescient/provider/openai.rb +10 -6
- data/lib/prescient/provider/xai.rb +139 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +117 -22
- data/schema/prescient.configuration.schema.json +153 -0
- data/sig/prescient.rbs +119 -2
- metadata +11 -2
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
# Load and validate Prescient configuration data from YAML.
|
|
7
|
+
class Prescient::ConfigurationLoader
|
|
8
|
+
# Supported YAML configuration schema version.
|
|
9
|
+
CONFIGURATION_VERSION = 1
|
|
10
|
+
|
|
11
|
+
# Allowed top-level configuration keys.
|
|
12
|
+
TOP_LEVEL_KEYS = [
|
|
13
|
+
'$schema',
|
|
14
|
+
'default_provider',
|
|
15
|
+
'default_provider_env',
|
|
16
|
+
'fallback_providers',
|
|
17
|
+
'fallback_providers_env',
|
|
18
|
+
'providers',
|
|
19
|
+
'retry_attempts',
|
|
20
|
+
'retry_attempts_env',
|
|
21
|
+
'retry_delay',
|
|
22
|
+
'retry_delay_env',
|
|
23
|
+
'sensitive_keys',
|
|
24
|
+
'sensitive_keys_env',
|
|
25
|
+
'timeout',
|
|
26
|
+
'timeout_env',
|
|
27
|
+
'version',
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# Provider names mapped to their adapter classes.
|
|
31
|
+
PROVIDER_TYPES = {
|
|
32
|
+
'ollama' => Prescient::Provider::Ollama,
|
|
33
|
+
'anthropic' => Prescient::Provider::Anthropic,
|
|
34
|
+
'openai' => Prescient::Provider::OpenAI,
|
|
35
|
+
'huggingface' => Prescient::Provider::HuggingFace,
|
|
36
|
+
'gemini' => Prescient::Provider::Gemini,
|
|
37
|
+
'mistral' => Prescient::Provider::Mistral,
|
|
38
|
+
'deepseek' => Prescient::Provider::DeepSeek,
|
|
39
|
+
'xai' => Prescient::Provider::XAI,
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Provider-specific keys shared by all supported adapters.
|
|
43
|
+
COMMON_PROVIDER_KEYS = [
|
|
44
|
+
'api_key',
|
|
45
|
+
'api_key_env',
|
|
46
|
+
'chat_model',
|
|
47
|
+
'chat_model_env',
|
|
48
|
+
'context_configs',
|
|
49
|
+
'context_configs_env',
|
|
50
|
+
'embedding_dimensions',
|
|
51
|
+
'embedding_dimensions_env',
|
|
52
|
+
'embedding_model',
|
|
53
|
+
'embedding_model_env',
|
|
54
|
+
'model',
|
|
55
|
+
'model_env',
|
|
56
|
+
'prompt_templates',
|
|
57
|
+
'prompt_templates_env',
|
|
58
|
+
'timeout',
|
|
59
|
+
'timeout_env',
|
|
60
|
+
'url',
|
|
61
|
+
'url_env',
|
|
62
|
+
].freeze
|
|
63
|
+
|
|
64
|
+
# Prompt template keys supported by provider configuration.
|
|
65
|
+
PROMPT_TEMPLATE_KEYS = ['system_prompt', 'no_context_template', 'with_context_template'].freeze
|
|
66
|
+
|
|
67
|
+
# Configuration attributes supported by the loader.
|
|
68
|
+
ATTR_KEYS = [
|
|
69
|
+
'default_provider',
|
|
70
|
+
'fallback_providers',
|
|
71
|
+
'retry_attempts',
|
|
72
|
+
'retry_delay',
|
|
73
|
+
'sensitive_keys',
|
|
74
|
+
'timeout',
|
|
75
|
+
].freeze
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
# Load and validate configuration from a YAML file.
|
|
79
|
+
# @param path [String] Configuration file path
|
|
80
|
+
# @param env [Hash] Environment variables used during expansion
|
|
81
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
82
|
+
def load_file(path, env: ENV)
|
|
83
|
+
new(env).load_file(path)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Load and validate configuration from YAML content.
|
|
87
|
+
# @param content [String] YAML configuration content
|
|
88
|
+
# @param env [Hash] Environment variables used during expansion
|
|
89
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
90
|
+
def load_yaml(content, env: ENV)
|
|
91
|
+
new(env).load_yaml(content)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Load and validate configuration from a Ruby hash.
|
|
95
|
+
# @param data [Hash] Configuration data
|
|
96
|
+
# @param env [Hash] Environment variables used during expansion
|
|
97
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
98
|
+
def load_hash(data, env: ENV)
|
|
99
|
+
new(env).load_hash(data)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def initialize(env = ENV)
|
|
104
|
+
@env = env
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Load configuration from a YAML file.
|
|
108
|
+
# @param path [String] Configuration file path
|
|
109
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
110
|
+
def load_file(path)
|
|
111
|
+
load_yaml(File.read(path), source: path)
|
|
112
|
+
rescue Errno::ENOENT
|
|
113
|
+
raise Prescient::Error, "Configuration file not found: #{path}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Load configuration from YAML content.
|
|
117
|
+
# @param content [String] YAML configuration content
|
|
118
|
+
# @param source [String, nil] Source label used in validation errors
|
|
119
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
120
|
+
def load_yaml(content, source: nil)
|
|
121
|
+
data = YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: true)
|
|
122
|
+
load_hash(data || {}, source:)
|
|
123
|
+
rescue Psych::SyntaxError => e
|
|
124
|
+
raise Prescient::Error, "Invalid YAML configuration#{" in #{source}" if source}: #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Load configuration from a Ruby hash.
|
|
128
|
+
# @param data [Hash] Configuration data
|
|
129
|
+
# @param source [String, nil] Source label used in validation errors
|
|
130
|
+
# @return [Prescient::Configuration] Loaded configuration
|
|
131
|
+
def load_hash(data, source: nil)
|
|
132
|
+
configuration = Prescient::Configuration.new
|
|
133
|
+
Prescient.send(:configure_default_providers, configuration, @env)
|
|
134
|
+
apply!(configuration, data, source:)
|
|
135
|
+
configuration
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Apply validated configuration data to an existing configuration object.
|
|
139
|
+
# @param configuration [Prescient::Configuration] Target configuration
|
|
140
|
+
# @param data [Hash] Configuration data
|
|
141
|
+
# @param source [String, nil] Source label used in validation errors
|
|
142
|
+
# @return [Prescient::Configuration] Updated configuration
|
|
143
|
+
def apply!(configuration, data, source: nil)
|
|
144
|
+
normalized = normalize_keys(data)
|
|
145
|
+
validate_root!(normalized, source:)
|
|
146
|
+
validate_version!(normalized, source:)
|
|
147
|
+
|
|
148
|
+
apply_scalar_settings(configuration, normalized, source:)
|
|
149
|
+
apply_provider_settings(configuration, normalized, source:)
|
|
150
|
+
configuration
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Return the packaged JSON Schema path.
|
|
154
|
+
# @return [String] Absolute path to the configuration schema
|
|
155
|
+
def self.schema_path
|
|
156
|
+
File.expand_path('../../schema/prescient.configuration.schema.json', __dir__)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def validate_root!(data, source:)
|
|
162
|
+
unless data.is_a?(Hash)
|
|
163
|
+
raise Prescient::Error, "Configuration#{" in #{source}" if source} must be a mapping"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
unknown_keys = data.keys.map(&:to_s) - TOP_LEVEL_KEYS
|
|
167
|
+
return if unknown_keys.empty?
|
|
168
|
+
|
|
169
|
+
raise Prescient::Error,
|
|
170
|
+
"Unknown configuration key#{'s' if unknown_keys.length > 1}: #{unknown_keys.join(', ')}" \
|
|
171
|
+
"#{" in #{source}" if source}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def apply_scalar_settings(configuration, data, source:)
|
|
175
|
+
apply_default_provider(configuration, data, source:)
|
|
176
|
+
apply_numeric_settings(configuration, data, source:)
|
|
177
|
+
apply_collection_settings(configuration, data, source:)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def apply_default_provider(configuration, data, source:)
|
|
181
|
+
return unless scalar_present?(data, :default_provider)
|
|
182
|
+
|
|
183
|
+
default_provider = resolve_scalar(data, :default_provider, source:)
|
|
184
|
+
if default_provider.nil? || default_provider.to_s.empty?
|
|
185
|
+
raise Prescient::Error, 'default_provider must be a non-empty string'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
configuration.default_provider = default_provider.to_sym
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def apply_numeric_settings(configuration, data, source:)
|
|
192
|
+
numeric_settings = {
|
|
193
|
+
timeout: [:coerce_integer, 'timeout'],
|
|
194
|
+
retry_attempts: [:coerce_integer, 'retry_attempts'],
|
|
195
|
+
retry_delay: [:coerce_float, 'retry_delay'],
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
numeric_settings.each do |key, (coercer, name)|
|
|
199
|
+
next unless scalar_present?(data, key)
|
|
200
|
+
|
|
201
|
+
value = resolve_scalar(data, key, source:)
|
|
202
|
+
setter = "#{key}="
|
|
203
|
+
configuration.public_send(setter, send(coercer, value, name))
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def apply_collection_settings(configuration, data, source:)
|
|
208
|
+
if scalar_present?(data, :fallback_providers)
|
|
209
|
+
value = resolve_scalar(data, :fallback_providers, source:)
|
|
210
|
+
configuration.fallback_providers = Array(value).map(&:to_sym)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
return unless scalar_present?(data, :sensitive_keys)
|
|
214
|
+
|
|
215
|
+
configuration.sensitive_keys = Array(resolve_scalar(data, :sensitive_keys, source:))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def apply_provider_settings(configuration, data, source:)
|
|
219
|
+
return unless key_present?(data, :providers)
|
|
220
|
+
|
|
221
|
+
providers = data[:providers]
|
|
222
|
+
unless providers.is_a?(Hash)
|
|
223
|
+
raise Prescient::Error, "Configuration#{" in #{source}" if source} providers must be a mapping"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
providers.each do |name, provider_data|
|
|
227
|
+
provider_name = name.to_sym
|
|
228
|
+
provider_settings = normalize_keys(provider_data)
|
|
229
|
+
validate_provider!(provider_name, provider_settings, source:)
|
|
230
|
+
|
|
231
|
+
provider_options = resolve_provider_options(provider_settings, source:)
|
|
232
|
+
configuration.add_provider(provider_name, PROVIDER_TYPES[provider_settings.fetch(:type).to_s],
|
|
233
|
+
**provider_options)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def validate_provider!(name, provider_data, source:)
|
|
238
|
+
validate_provider_shape!(name, provider_data, source:)
|
|
239
|
+
validate_provider_type!(name, provider_data, source:)
|
|
240
|
+
validate_provider_keys!(name, provider_data, source:)
|
|
241
|
+
validate_prompt_templates!(name, provider_data, source:)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def validate_provider_shape!(name, provider_data, source:)
|
|
245
|
+
return if provider_data.is_a?(Hash)
|
|
246
|
+
|
|
247
|
+
raise Prescient::Error, "Provider #{name.inspect}#{" in #{source}" if source} must be a mapping"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def validate_provider_type!(name, provider_data, source:)
|
|
251
|
+
return if provider_data.key?(:type)
|
|
252
|
+
|
|
253
|
+
raise Prescient::Error, "Provider #{name}#{" in #{source}" if source} must define type"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def validate_provider_keys!(name, provider_data, source:)
|
|
257
|
+
unknown_keys = provider_data.keys.map(&:to_s) - (['type'] + COMMON_PROVIDER_KEYS)
|
|
258
|
+
return if unknown_keys.empty?
|
|
259
|
+
|
|
260
|
+
raise Prescient::Error,
|
|
261
|
+
"Unknown provider configuration key#{'s' if unknown_keys.length > 1} for #{name}: " \
|
|
262
|
+
"#{unknown_keys.join(', ')}" \
|
|
263
|
+
"#{" in #{source}" if source}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def validate_prompt_templates!(name, provider_data, source:)
|
|
267
|
+
templates = provider_data[:prompt_templates]
|
|
268
|
+
return if templates.nil?
|
|
269
|
+
|
|
270
|
+
source_suffix = " in #{source}" if source
|
|
271
|
+
unless templates.is_a?(Hash)
|
|
272
|
+
raise Prescient::Error, "prompt_templates for #{name} must be a mapping#{source_suffix}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
unknown_keys = templates.keys.map(&:to_s) - PROMPT_TEMPLATE_KEYS
|
|
276
|
+
return if unknown_keys.empty?
|
|
277
|
+
|
|
278
|
+
message = "Unknown prompt template key#{'s' if unknown_keys.length > 1} for #{name}: " \
|
|
279
|
+
"#{unknown_keys.join(', ')}#{source_suffix}"
|
|
280
|
+
raise Prescient::Error, message
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def resolve_provider_options(provider_data, source:)
|
|
284
|
+
provider_type = provider_data[:type].to_s
|
|
285
|
+
provider_class = PROVIDER_TYPES[provider_type]
|
|
286
|
+
unless provider_class
|
|
287
|
+
raise Prescient::Error,
|
|
288
|
+
"Unknown provider type #{provider_type.inspect}#{" in #{source}" if source}"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
provider_data.each_with_object({}) do |(key, value), options|
|
|
292
|
+
option = resolve_provider_option(provider_data, key, value, source:)
|
|
293
|
+
options[option.first] = option.last if option
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def resolve_provider_option(provider_data, key, value, source:)
|
|
298
|
+
return if key == :type
|
|
299
|
+
return if key.to_s.end_with?('_env') && value.nil?
|
|
300
|
+
|
|
301
|
+
if key.to_s.end_with?('_env')
|
|
302
|
+
base_key = key.to_s.delete_suffix('_env').to_sym
|
|
303
|
+
if provider_data.key?(base_key)
|
|
304
|
+
raise Prescient::Error,
|
|
305
|
+
"Provider configuration cannot combine #{base_key} and #{key}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
[base_key, resolve_env_value(value, source:)]
|
|
309
|
+
else
|
|
310
|
+
[key, coerce_provider_option(key, resolve_value(value, source:))]
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def resolve_scalar(data, key, source:)
|
|
315
|
+
env_key = :"#{key}_env"
|
|
316
|
+
if key_present?(data, key) && key_present?(data, env_key)
|
|
317
|
+
raise Prescient::Error, "Configuration cannot combine #{key} and #{key}_env"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
return resolve_env_value(data[env_key], source:) if key_present?(data, env_key)
|
|
321
|
+
|
|
322
|
+
value = data[key]
|
|
323
|
+
return nil if value.nil?
|
|
324
|
+
|
|
325
|
+
resolve_value(value, source:)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def resolve_value(value, source:)
|
|
329
|
+
case value
|
|
330
|
+
when Hash
|
|
331
|
+
resolve_hash_value(value, source:)
|
|
332
|
+
when Array
|
|
333
|
+
value.map { |item| resolve_value(item, source:) }
|
|
334
|
+
when String
|
|
335
|
+
interpolate_env(value, source:)
|
|
336
|
+
else
|
|
337
|
+
value
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def resolve_hash_value(value, source:)
|
|
342
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
343
|
+
key_name = key.to_s
|
|
344
|
+
if key_name.end_with?('_env')
|
|
345
|
+
base_key = key_name.delete_suffix('_env').to_sym
|
|
346
|
+
if value.key?(base_key) || value.key?(base_key.to_s)
|
|
347
|
+
raise Prescient::Error, "Configuration cannot combine #{base_key} and #{key_name}"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
result[base_key] = resolve_env_value(nested_value, source:)
|
|
351
|
+
else
|
|
352
|
+
result[key.to_sym] = resolve_value(nested_value, source:)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def resolve_env_value(value, source:)
|
|
358
|
+
env_name = resolve_value(value, source:)
|
|
359
|
+
unless env_name.is_a?(String) && !env_name.empty?
|
|
360
|
+
raise Prescient::Error, 'Environment variable name must be a non-empty string'
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
raw_value = @env.fetch(env_name)
|
|
364
|
+
parsed_value = YAML.safe_load(raw_value, permitted_classes: [], permitted_symbols: [], aliases: true)
|
|
365
|
+
parsed_value.nil? ? raw_value : parsed_value
|
|
366
|
+
rescue KeyError
|
|
367
|
+
raise Prescient::Error, "Environment variable not set: #{env_name}#{" in #{source}" if source}"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def interpolate_env(value, source:)
|
|
371
|
+
value.gsub(/\$\{([A-Z0-9_]+)\}/) do
|
|
372
|
+
env_name = Regexp.last_match(1)
|
|
373
|
+
@env.fetch(env_name)
|
|
374
|
+
rescue KeyError
|
|
375
|
+
raise Prescient::Error, "Environment variable not set: #{env_name}#{" in #{source}" if source}"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def normalize_keys(value)
|
|
380
|
+
case value
|
|
381
|
+
when Hash
|
|
382
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
383
|
+
result[key.to_sym] = normalize_keys(nested_value)
|
|
384
|
+
end
|
|
385
|
+
when Array
|
|
386
|
+
value.map { |item| normalize_keys(item) }
|
|
387
|
+
else
|
|
388
|
+
value
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def key_present?(data, key)
|
|
393
|
+
data.key?(key) || data.key?(key.to_s)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def scalar_present?(data, key)
|
|
397
|
+
key_present?(data, key) || key_present?(data, :"#{key}_env")
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def coerce_integer(value, name)
|
|
401
|
+
return value if value.is_a?(Integer)
|
|
402
|
+
return value.to_i if value.is_a?(Numeric)
|
|
403
|
+
|
|
404
|
+
Integer(value)
|
|
405
|
+
rescue ArgumentError, TypeError
|
|
406
|
+
raise Prescient::Error, "#{name} must be an integer"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def coerce_float(value, name)
|
|
410
|
+
return value.to_f if value.is_a?(Numeric)
|
|
411
|
+
|
|
412
|
+
Float(value)
|
|
413
|
+
rescue ArgumentError, TypeError
|
|
414
|
+
raise Prescient::Error, "#{name} must be a number"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def coerce_provider_option(key, value)
|
|
418
|
+
case key.to_sym
|
|
419
|
+
when :embedding_dimensions
|
|
420
|
+
coerce_integer(value, 'embedding_dimensions')
|
|
421
|
+
when :timeout
|
|
422
|
+
coerce_integer(value, 'timeout')
|
|
423
|
+
else
|
|
424
|
+
value
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def validate_version!(data, source:)
|
|
429
|
+
return unless key_present?(data, :version)
|
|
430
|
+
|
|
431
|
+
version = resolve_value(data[:version], source:)
|
|
432
|
+
return if version == CONFIGURATION_VERSION
|
|
433
|
+
|
|
434
|
+
raise Prescient::Error,
|
|
435
|
+
"Unsupported configuration version #{version.inspect}#{" in #{source}" if source}"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
@@ -37,7 +37,7 @@ class Prescient::Provider::Anthropic < Prescient::Base
|
|
|
37
37
|
'anthropic-version' => '2023-06-01',
|
|
38
38
|
},
|
|
39
39
|
body: {
|
|
40
|
-
model: @options[:model],
|
|
40
|
+
model: options[:model] || @options[:model],
|
|
41
41
|
max_tokens: options[:max_tokens] || 2000,
|
|
42
42
|
temperature: options[:temperature] || 0.7,
|
|
43
43
|
messages: [
|
|
@@ -55,7 +55,7 @@ class Prescient::Provider::Anthropic < Prescient::Base
|
|
|
55
55
|
|
|
56
56
|
{
|
|
57
57
|
response: content.strip,
|
|
58
|
-
model: @options[:model],
|
|
58
|
+
model: options[:model] || @options[:model],
|
|
59
59
|
provider: 'anthropic',
|
|
60
60
|
processing_time: nil,
|
|
61
61
|
metadata: {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'httparty'
|
|
4
|
+
|
|
5
|
+
# DeepSeek API provider adapter.
|
|
6
|
+
class Prescient::Provider::DeepSeek < Prescient::Base
|
|
7
|
+
include HTTParty
|
|
8
|
+
|
|
9
|
+
base_uri 'https://api.deepseek.com'
|
|
10
|
+
|
|
11
|
+
def initialize(**options)
|
|
12
|
+
super
|
|
13
|
+
@provider_name = 'DeepSeek'
|
|
14
|
+
self.class.default_timeout(@options[:timeout] || 60)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# DeepSeek does not currently provide an embeddings endpoint.
|
|
18
|
+
# @raise [Prescient::Error] Always, because embeddings are unsupported
|
|
19
|
+
def generate_embedding(_text, **_options)
|
|
20
|
+
raise Prescient::Error, 'DeepSeek provider does not support embeddings.'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Generate a response through DeepSeek's OpenAI-compatible chat API.
|
|
24
|
+
# @param prompt [String] Prompt to send
|
|
25
|
+
# @param context_items [Array<Hash, String>] Optional context items
|
|
26
|
+
# @return [Hash] Normalized response data
|
|
27
|
+
def generate_response(prompt, context_items = [], **options)
|
|
28
|
+
handle_errors do
|
|
29
|
+
model = options[:model] || @options[:chat_model]
|
|
30
|
+
response = self.class.post(
|
|
31
|
+
'/chat/completions',
|
|
32
|
+
headers: api_headers,
|
|
33
|
+
body: {
|
|
34
|
+
model: model,
|
|
35
|
+
messages: [{ role: 'user', content: build_prompt(prompt, context_items) }],
|
|
36
|
+
max_tokens: options[:max_tokens] || 2000,
|
|
37
|
+
temperature: options[:temperature] || 0.7,
|
|
38
|
+
top_p: options[:top_p] || 0.9,
|
|
39
|
+
}.to_json,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
validate_response!(response, 'text generation')
|
|
43
|
+
|
|
44
|
+
parsed_response = response.parsed_response
|
|
45
|
+
content = parsed_response.dig('choices', 0, 'message', 'content')
|
|
46
|
+
raise Prescient::InvalidResponseError, 'No response generated' unless content.is_a?(String) && !content.empty?
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
response: content.strip,
|
|
50
|
+
model: model,
|
|
51
|
+
provider: 'deepseek',
|
|
52
|
+
processing_time: nil,
|
|
53
|
+
metadata: {
|
|
54
|
+
usage: parsed_response['usage'],
|
|
55
|
+
finish_reason: parsed_response.dig('choices', 0, 'finish_reason'),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check whether the configured DeepSeek model is available.
|
|
62
|
+
# @return [Hash] Provider health information
|
|
63
|
+
def health_check
|
|
64
|
+
handle_errors do
|
|
65
|
+
response = self.class.get('/models', headers: api_headers)
|
|
66
|
+
|
|
67
|
+
if response.success?
|
|
68
|
+
models = response.parsed_response['data'] || []
|
|
69
|
+
model_available = models.any? { |model| model['id'] == @options[:chat_model] }
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
status: 'healthy',
|
|
73
|
+
provider: 'deepseek',
|
|
74
|
+
reachable: true,
|
|
75
|
+
models_available: models.map { |model| model['id'] },
|
|
76
|
+
chat_model: { name: @options[:chat_model], available: model_available },
|
|
77
|
+
ready: model_available,
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
{
|
|
81
|
+
status: 'unhealthy',
|
|
82
|
+
provider: 'deepseek',
|
|
83
|
+
reachable: true,
|
|
84
|
+
error: "HTTP #{response.code}",
|
|
85
|
+
message: response.message,
|
|
86
|
+
ready: false,
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
rescue Prescient::Error => e
|
|
91
|
+
{
|
|
92
|
+
status: 'unavailable',
|
|
93
|
+
provider: 'deepseek',
|
|
94
|
+
reachable: false,
|
|
95
|
+
error: e.class.name,
|
|
96
|
+
message: e.message,
|
|
97
|
+
ready: false,
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# List models available to the configured DeepSeek API key.
|
|
102
|
+
# @return [Array<Hash>] Model descriptors
|
|
103
|
+
def list_models
|
|
104
|
+
handle_errors do
|
|
105
|
+
response = self.class.get('/models', headers: api_headers)
|
|
106
|
+
validate_response!(response, 'model listing')
|
|
107
|
+
|
|
108
|
+
(response.parsed_response['data'] || []).map do |model|
|
|
109
|
+
{
|
|
110
|
+
name: model['id'],
|
|
111
|
+
object: model['object'],
|
|
112
|
+
created: model['created'],
|
|
113
|
+
owned_by: model['owned_by'],
|
|
114
|
+
permission: model['permission'],
|
|
115
|
+
}.compact
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
protected
|
|
121
|
+
|
|
122
|
+
def validate_configuration!
|
|
123
|
+
required_options = [:api_key, :chat_model]
|
|
124
|
+
missing_options = required_options.select { |option| @options[option].nil? }
|
|
125
|
+
|
|
126
|
+
return unless missing_options.any?
|
|
127
|
+
|
|
128
|
+
raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def api_headers
|
|
134
|
+
{
|
|
135
|
+
'Content-Type' => 'application/json',
|
|
136
|
+
'Authorization' => "Bearer #{@options[:api_key]}",
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|