prescient 0.4.0 → 0.6.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.
data/lib/prescient/cli.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'json'
4
4
  require 'optparse'
5
+ require 'yaml'
6
+ require_relative '../prescient'
5
7
 
6
8
  # Command-line interface for common Prescient operations.
7
9
  class Prescient::CLI
@@ -9,6 +11,90 @@ class Prescient::CLI
9
11
  # @return [Array<String>] Output format names
10
12
  FORMATS = ['text', 'json'].freeze
11
13
 
14
+ # Schema URL and annotated starter configuration for `config example`.
15
+ CONFIGURATION_EXAMPLE = <<~YAML
16
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/kanutocd/prescient/refs/heads/main/schema/prescient.configuration.schema.json
17
+ #
18
+ # Prescient configuration example.
19
+ #
20
+ # Precedence, from lowest to highest:
21
+ # 1. Built-in defaults and provider environment variables.
22
+ # 2. Values in this YAML file.
23
+ # 3. Per-operation CLI overrides such as --provider and --chat-model.
24
+ #
25
+ # Use `prescient config validate` after editing this file.
26
+ # Keep credentials out of source control; use *_env references instead.
27
+ version: 1
28
+
29
+ # Global behavior.
30
+ default_provider: ollama
31
+ timeout: 30
32
+ retry_attempts: 3
33
+ retry_delay: 1.0
34
+ fallback_providers: []
35
+ sensitive_keys:
36
+ - api_key
37
+ - password
38
+ - token
39
+ - secret
40
+
41
+ providers:
42
+ # Local Ollama requires no API key.
43
+ ollama:
44
+ type: ollama
45
+ url: http://localhost:11434
46
+ embedding_model: nomic-embed-text
47
+ chat_model: llama3.2:3b
48
+ # prompt_templates:
49
+ # system_prompt: You are a concise assistant.
50
+ # no_context_template: "%<system_prompt>s\\n\\nUser: %<query>s"
51
+ # with_context_template: "%<system_prompt>s\\n\\nContext:\\n%<context>s\\n\\nUser: %<query>s"
52
+
53
+ # Uncomment a cloud provider and set its credential in the environment.
54
+ # openai:
55
+ # type: openai
56
+ # api_key_env: OPENAI_API_KEY
57
+ # embedding_model: text-embedding-3-small
58
+ # chat_model: gpt-4.1-mini
59
+ # prompt_templates:
60
+ # system_prompt: You are a concise assistant.
61
+ # no_context_template: "%<system_prompt>s\n\nUser: %<query>s"
62
+
63
+ # anthropic:
64
+ # type: anthropic
65
+ # api_key_env: ANTHROPIC_API_KEY
66
+ # model: claude-sonnet-4-20250514
67
+
68
+ # gemini:
69
+ # type: gemini
70
+ # api_key_env: GEMINI_API_KEY
71
+ # embedding_model: gemini-embedding-001
72
+ # chat_model: gemini-2.5-flash
73
+
74
+ # mistral:
75
+ # type: mistral
76
+ # api_key_env: MISTRAL_API_KEY
77
+ # embedding_model: mistral-embed
78
+ # chat_model: mistral-large-latest
79
+
80
+ # DeepSeek supports text generation, but not embeddings.
81
+ # deepseek:
82
+ # type: deepseek
83
+ # api_key_env: DEEPSEEK_API_KEY
84
+ # chat_model: deepseek-v4-flash
85
+
86
+ # xai:
87
+ # type: xai
88
+ # api_key_env: XAI_API_KEY
89
+ # chat_model: grok-4.5
90
+
91
+ # huggingface:
92
+ # type: huggingface
93
+ # api_key_env: HUGGINGFACE_API_KEY
94
+ # embedding_model: sentence-transformers/all-MiniLM-L6-v2
95
+ # chat_model: google/gemma-2-2b-it
96
+ YAML
97
+
12
98
  # Raised when command-line arguments are invalid or incomplete.
13
99
  class UsageError < StandardError; end
14
100
 
@@ -42,10 +128,22 @@ class Prescient::CLI
42
128
  @errors = errors
43
129
  end
44
130
 
131
+ # Execute the CLI command and return its process status.
132
+ # @return [Integer] Process exit status
45
133
  def run
134
+ config_path = extract_global_config_path
135
+ Prescient.load_configuration(config_path) if config_path || ENV['PRESCIENT_CONFIG']
136
+
46
137
  command = @arguments.shift
47
138
  return print_help(2) unless command
48
139
 
140
+ run_command(command)
141
+ end
142
+
143
+ # Dispatch a parsed command to its handler.
144
+ # @param command [String] Command name
145
+ # @return [Integer] Process exit status
146
+ def run_command(command)
49
147
  case command
50
148
  when 'providers' then providers
51
149
  when 'health' then health
@@ -118,8 +216,15 @@ class Prescient::CLI
118
216
 
119
217
  def config
120
218
  subcommand = @arguments.shift
121
- raise UsageError, "unknown config command #{subcommand.inspect}" unless subcommand == 'validate'
219
+ case subcommand
220
+ when 'validate' then validate_config_command
221
+ when 'example' then configuration_example_command
222
+ else
223
+ raise UsageError, "unknown config command #{subcommand.inspect}"
224
+ end
225
+ end
122
226
 
227
+ def validate_config_command
123
228
  options = parse_options('Validate the current configuration')
124
229
  return options if options.is_a?(Integer)
125
230
 
@@ -132,6 +237,14 @@ class Prescient::CLI
132
237
  0
133
238
  end
134
239
 
240
+ def configuration_example_command
241
+ options = parse_options('Generate an annotated YAML configuration example')
242
+ return options if options.is_a?(Integer)
243
+
244
+ @output.write(CONFIGURATION_EXAMPLE)
245
+ 0
246
+ end
247
+
135
248
  def validate_configuration
136
249
  configuration = Prescient.configuration
137
250
  unless configuration.provider(configuration.default_provider)
@@ -162,6 +275,9 @@ class Prescient::CLI
162
275
  end
163
276
 
164
277
  def add_common_options(parser, options)
278
+ parser.on('--config PATH', 'Load configuration from a YAML file') do |value|
279
+ options[:config] = value
280
+ end
165
281
  parser.on('--format FORMAT', FORMATS, "Output format (#{FORMATS.join(', ')})") do |value|
166
282
  options[:format] = value
167
283
  end
@@ -206,6 +322,18 @@ class Prescient::CLI
206
322
  parser.on('--chat-model NAME', 'Override the chat model') do |value|
207
323
  options[:chat_model] = value
208
324
  end
325
+ parser.on('--system-prompt TEXT', 'Override the system prompt') do |value|
326
+ options[:system_prompt] = value
327
+ end
328
+ parser.on('--no-context-template TEXT', 'Override the no-context prompt template') do |value|
329
+ options[:no_context_template] = value
330
+ end
331
+ parser.on('--with-context-template TEXT', 'Override the with-context prompt template') do |value|
332
+ options[:with_context_template] = value
333
+ end
334
+ parser.on('--prompt-templates-file PATH', 'Load prompt templates from a YAML file') do |value|
335
+ options[:prompt_templates_file] = value
336
+ end
209
337
  end
210
338
 
211
339
  def add_credential_options(parser, options)
@@ -228,12 +356,38 @@ class Prescient::CLI
228
356
 
229
357
  def provider_options(options)
230
358
  {
231
- api_key: api_key_override(options),
232
- embedding_model: options[:embedding_model],
233
- chat_model: options[:chat_model],
359
+ api_key: api_key_override(options),
360
+ embedding_model: options[:embedding_model],
361
+ chat_model: options[:chat_model],
362
+ prompt_templates: prompt_templates(options),
234
363
  }.compact
235
364
  end
236
365
 
366
+ def prompt_templates(options)
367
+ templates = if options[:prompt_templates_file]
368
+ data = YAML.safe_load_file(
369
+ options[:prompt_templates_file],
370
+ permitted_classes: [],
371
+ permitted_symbols: [],
372
+ aliases: true,
373
+ )
374
+ raise UsageError, 'prompt templates file must contain a mapping' unless data.is_a?(Hash)
375
+
376
+ data.transform_keys(&:to_sym)
377
+ else
378
+ {}
379
+ end
380
+
381
+ [:system_prompt, :no_context_template, :with_context_template].each do |key|
382
+ templates[key] = options[key] if options[key]
383
+ end
384
+ templates.empty? ? nil : templates
385
+ rescue Errno::ENOENT
386
+ raise UsageError, "prompt templates file not found: #{options[:prompt_templates_file]}"
387
+ rescue Psych::SyntaxError => e
388
+ raise UsageError, "invalid prompt templates YAML: #{e.message}"
389
+ end
390
+
237
391
  def api_key_override(options)
238
392
  return options[:api_key] if options[:api_key]
239
393
  return ENV.fetch(options[:api_key_env]) if options[:api_key_env]
@@ -272,16 +426,55 @@ class Prescient::CLI
272
426
  generate TEXT Generate a text response
273
427
  embed TEXT Generate an embedding
274
428
  config validate Validate the current configuration
429
+ config example Generate an annotated YAML configuration example
275
430
 
276
431
  Options:
432
+ --config PATH Load configuration from a YAML file
277
433
  --provider NAME Select a provider
278
434
  --model NAME Override the selected operation's model
279
435
  --chat-model NAME Override the chat model
280
436
  --embedding-model NAME Override the embedding model
437
+ --system-prompt TEXT Override the system prompt
438
+ --no-context-template TEXT
439
+ Override the no-context prompt template
440
+ --with-context-template TEXT
441
+ Override the with-context prompt template
442
+ --prompt-templates-file PATH
443
+ Load prompt templates from a YAML file
281
444
  --api-key KEY Use an API key for the operation
282
445
  --api-key-env NAME Read the API key from an environment variable
283
446
  --format FORMAT Use text or json output
284
447
  HELP
285
448
  status
286
449
  end
450
+
451
+ def extract_global_config_path
452
+ config_path = nil
453
+ filtered_arguments = []
454
+ index = 0
455
+
456
+ while index < @arguments.length
457
+ argument = @arguments[index]
458
+ if argument == '--config'
459
+ value = @arguments[index + 1]
460
+ raise UsageError, '--config requires a path' unless value
461
+
462
+ config_path = value
463
+ index += 2
464
+ next
465
+ end
466
+
467
+ if argument.start_with?('--config=')
468
+ config_path = argument.split('=', 2).last
469
+ index += 1
470
+ next
471
+ end
472
+
473
+ filtered_arguments << argument
474
+ index += 1
475
+ end
476
+
477
+ @arguments = filtered_arguments
478
+ config_path
479
+ end
287
480
  end
@@ -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