prescient 0.2.0 → 0.4.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.
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'optparse'
5
+
6
+ # Command-line interface for common Prescient operations.
7
+ class Prescient::CLI
8
+ # Supported output formats.
9
+ # @return [Array<String>] Output format names
10
+ FORMATS = ['text', 'json'].freeze
11
+
12
+ # Raised when command-line arguments are invalid or incomplete.
13
+ class UsageError < StandardError; end
14
+
15
+ # Run the CLI and return a process exit status.
16
+ #
17
+ # @param arguments [Array<String>] Command-line arguments
18
+ # @param input [IO] Input stream used for stdin prompts
19
+ # @param output [IO] Output stream for command results
20
+ # @param errors [IO] Output stream for diagnostics
21
+ # @return [Integer] Process exit status
22
+ def self.run(arguments, input: $stdin, output: $stdout, errors: $stderr)
23
+ new(arguments, input:, output:, errors:).run
24
+ rescue UsageError, OptionParser::ParseError => e
25
+ errors.puts "prescient: #{e.message}"
26
+ 2
27
+ rescue Prescient::Error => e
28
+ errors.puts "prescient: #{e.message}"
29
+ 1
30
+ end
31
+
32
+ # Initialize a CLI runner with injectable streams.
33
+ #
34
+ # @param arguments [Array<String>] Command-line arguments
35
+ # @param input [IO] Input stream used for stdin prompts
36
+ # @param output [IO] Output stream for command results
37
+ # @param errors [IO] Output stream for diagnostics
38
+ def initialize(arguments, input:, output:, errors:)
39
+ @arguments = arguments.dup
40
+ @input = input
41
+ @output = output
42
+ @errors = errors
43
+ end
44
+
45
+ def run
46
+ command = @arguments.shift
47
+ return print_help(2) unless command
48
+
49
+ case command
50
+ when 'providers' then providers
51
+ when 'health' then health
52
+ when 'generate' then generate
53
+ when 'embed' then embed
54
+ when 'config' then config
55
+ when 'help', '--help', '-h' then print_help(0)
56
+ else
57
+ raise UsageError, "unknown command #{command.inspect}; run 'prescient help'"
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def providers
64
+ options = parse_options('List configured providers')
65
+ return options if options.is_a?(Integer)
66
+
67
+ provider_list = Prescient.configuration.providers.map { |name, registration|
68
+ { name: name.to_s, class: registration[:class].name }
69
+ }
70
+
71
+ if options[:format] == 'json'
72
+ print_json(providers: provider_list)
73
+ else
74
+ provider_list.each { |provider| @output.puts "#{provider[:name]}\t#{provider[:class]}" }
75
+ end
76
+ 0
77
+ end
78
+
79
+ def health
80
+ options = parse_options('Check provider health')
81
+ return options if options.is_a?(Integer)
82
+
83
+ names = options[:provider] ? [options[:provider].to_sym] : Prescient.configuration.providers.keys
84
+ raise UsageError, 'no providers are configured' if names.empty?
85
+
86
+ results = names.to_h { |name| [name.to_s, Prescient.health_check(provider: name)] }
87
+ output_health(results, options[:format])
88
+ results.values.all? { |result| result[:reachable] != false } ? 0 : 1
89
+ end
90
+
91
+ def generate
92
+ options = parse_options('Generate a text response', fallback: true)
93
+ return options if options.is_a?(Integer)
94
+
95
+ prompt = read_text(options[:arguments], 'prompt')
96
+ client = client_for(options)
97
+ response = client.generate_response(prompt, **model_options(options))
98
+
99
+ options[:format] == 'json' ? print_json(response) : @output.puts(response[:response])
100
+ 0
101
+ end
102
+
103
+ def embed
104
+ options = parse_options('Generate an embedding', fallback: true)
105
+ return options if options.is_a?(Integer)
106
+
107
+ text = read_text(options[:arguments], 'text')
108
+ client = client_for(options)
109
+ embedding = client.generate_embedding(text, **model_options(options))
110
+
111
+ if options[:format] == 'json'
112
+ print_json(embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s)
113
+ else
114
+ @output.puts JSON.generate(embedding)
115
+ end
116
+ 0
117
+ end
118
+
119
+ def config
120
+ subcommand = @arguments.shift
121
+ raise UsageError, "unknown config command #{subcommand.inspect}" unless subcommand == 'validate'
122
+
123
+ options = parse_options('Validate the current configuration')
124
+ return options if options.is_a?(Integer)
125
+
126
+ validate_configuration
127
+ if options[:format] == 'json'
128
+ print_json(valid: true, providers: Prescient.configuration.providers.keys.map(&:to_s))
129
+ else
130
+ @output.puts 'configuration valid'
131
+ end
132
+ 0
133
+ end
134
+
135
+ def validate_configuration
136
+ configuration = Prescient.configuration
137
+ unless configuration.provider(configuration.default_provider)
138
+ raise Prescient::Error, 'default provider is not configured'
139
+ end
140
+
141
+ configuration.providers.each_key { |name| configuration.provider(name) }
142
+ end
143
+
144
+ def parse_options(description, fallback: false)
145
+ options = { format: 'text', fallback: fallback }
146
+ parser = OptionParser.new do |parser|
147
+ parser.banner = "Usage: prescient #{@arguments.first || 'command'} [options]"
148
+ parser.separator description
149
+ add_common_options(parser, options)
150
+ parser.on('--no-fallback', 'Disable provider fallback') { options[:fallback] = false } if fallback
151
+ parser.on('-h', '--help', 'Show command help') do
152
+ @output.puts parser
153
+ throw :help_shown, 0
154
+ end
155
+ end
156
+
157
+ result = catch(:help_shown) { parse_arguments(parser) }
158
+ return result unless result.nil?
159
+
160
+ options[:arguments] = @arguments
161
+ options
162
+ end
163
+
164
+ def add_common_options(parser, options)
165
+ parser.on('--format FORMAT', FORMATS, "Output format (#{FORMATS.join(', ')})") do |value|
166
+ options[:format] = value
167
+ end
168
+ parser.on('--provider NAME', 'Use a specific provider') do |value|
169
+ options[:provider] = value
170
+ end
171
+ add_model_options(parser, options)
172
+ add_credential_options(parser, options)
173
+ end
174
+
175
+ # Parse command arguments and return nil when parsing completes.
176
+ #
177
+ # @param parser [OptionParser] Configured command option parser
178
+ # @return [nil]
179
+ def parse_arguments(parser)
180
+ parser.parse!(@arguments)
181
+ nil
182
+ end
183
+
184
+ def model_options(options)
185
+ options[:model] ? { model: options[:model] } : {}
186
+ end
187
+
188
+ def client_for(options)
189
+ validate_override_options(options)
190
+ Prescient.client(
191
+ options[:provider]&.to_sym,
192
+ enable_fallback: options[:fallback],
193
+ provider_options: provider_options(options),
194
+ )
195
+ rescue KeyError => e
196
+ raise UsageError, "environment variable not set: #{e.key}"
197
+ end
198
+
199
+ def add_model_options(parser, options)
200
+ parser.on('--model NAME', 'Override the configured model') do |value|
201
+ options[:model] = value
202
+ end
203
+ parser.on('--embedding-model NAME', 'Override the embedding model') do |value|
204
+ options[:embedding_model] = value
205
+ end
206
+ parser.on('--chat-model NAME', 'Override the chat model') do |value|
207
+ options[:chat_model] = value
208
+ end
209
+ end
210
+
211
+ def add_credential_options(parser, options)
212
+ parser.on('--api-key KEY', 'Use an API key for this operation') do |value|
213
+ options[:api_key] = value
214
+ end
215
+ parser.on('--api-key-env NAME', 'Read the API key from this environment variable') do |value|
216
+ options[:api_key_env] = value
217
+ end
218
+ end
219
+
220
+ def validate_override_options(options)
221
+ if options[:model] && (options[:embedding_model] || options[:chat_model])
222
+ raise UsageError, '--model cannot be combined with --embedding-model or --chat-model'
223
+ end
224
+ return unless options[:api_key] && options[:api_key_env]
225
+
226
+ raise UsageError, '--api-key cannot be combined with --api-key-env'
227
+ end
228
+
229
+ def provider_options(options)
230
+ {
231
+ api_key: api_key_override(options),
232
+ embedding_model: options[:embedding_model],
233
+ chat_model: options[:chat_model],
234
+ }.compact
235
+ end
236
+
237
+ def api_key_override(options)
238
+ return options[:api_key] if options[:api_key]
239
+ return ENV.fetch(options[:api_key_env]) if options[:api_key_env]
240
+
241
+ nil
242
+ end
243
+
244
+ def output_health(results, format)
245
+ if format == 'json'
246
+ print_json(results)
247
+ else
248
+ results.each do |name, result|
249
+ @output.puts '%<name>-12s %<status>s' % { name: name, status: result[:status] || 'unknown' }
250
+ end
251
+ end
252
+ end
253
+
254
+ def read_text(arguments, label)
255
+ return arguments.join(' ') unless arguments.empty?
256
+ return @input.read unless @input.tty?
257
+
258
+ raise UsageError, "missing #{label}; provide it as an argument or through stdin"
259
+ end
260
+
261
+ def print_json(value)
262
+ @output.puts JSON.generate(value)
263
+ end
264
+
265
+ def print_help(status)
266
+ @output.puts <<~HELP
267
+ Usage: prescient COMMAND [options]
268
+
269
+ Commands:
270
+ providers List configured providers
271
+ health Check provider health
272
+ generate TEXT Generate a text response
273
+ embed TEXT Generate an embedding
274
+ config validate Validate the current configuration
275
+
276
+ Options:
277
+ --provider NAME Select a provider
278
+ --model NAME Override the selected operation's model
279
+ --chat-model NAME Override the chat model
280
+ --embedding-model NAME Override the embedding model
281
+ --api-key KEY Use an API key for the operation
282
+ --api-key-env NAME Read the API key from an environment variable
283
+ --format FORMAT Use text or json output
284
+ HELP
285
+ status
286
+ end
287
+ end
@@ -16,26 +16,25 @@ module Prescient
16
16
  # client = Prescient::Client.new # Uses configured default
17
17
  # puts client.provider_name # => :ollama (or configured default)
18
18
  #
19
- # @author Claude Code
20
- # @since 1.0.0
21
19
  class Client
22
20
  # @return [Symbol] The name of the provider being used
23
21
  attr_reader :provider_name
24
22
 
25
- # @return [Base] The underlying provider instance
23
+ # @return [Prescient::Base] The underlying provider instance
26
24
  attr_reader :provider
27
25
 
28
26
  # Initialize a new client with the specified provider
29
27
  #
30
28
  # @param provider_name [Symbol, nil] Name of provider to use, or nil for default
31
29
  # @param enable_fallback [Boolean] Whether to enable automatic fallback to other providers
30
+ # @param provider_options [Hash] Temporary options for the selected provider
32
31
  # @raise [Prescient::Error] If the specified provider is not configured
33
- def initialize(provider_name = nil, enable_fallback: true)
32
+ def initialize(provider_name = nil, enable_fallback: true, provider_options: {})
34
33
  @provider_name = provider_name || Prescient.configuration.default_provider
35
- @provider = Prescient.configuration.provider(@provider_name)
34
+ @provider = provider_with_options(@provider_name, provider_options)
36
35
  @enable_fallback = enable_fallback
37
36
 
38
- raise Prescient::Error, "Provider not found: #{@provider_name}" unless @provider
37
+ raise Prescient::Error, "Provider not configured: #{@provider_name}" unless @provider
39
38
  end
40
39
 
41
40
  # Generate embeddings for the given text
@@ -84,14 +83,14 @@ module Prescient
84
83
 
85
84
  # Check the health status of the provider
86
85
  #
87
- # @return [Hash] Health status information
86
+ # @return [Hash] Health status information from the selected provider
88
87
  def health_check
89
88
  @provider.health_check
90
89
  end
91
90
 
92
91
  # Check if the provider is currently available
93
92
  #
94
- # @return [Boolean] true if provider is healthy and available
93
+ # @return [Boolean] true if the provider currently passes its availability check
95
94
  def available?
96
95
  @provider.available?
97
96
  end
@@ -101,7 +100,8 @@ module Prescient
101
100
  # Returns details about the provider including its availability
102
101
  # and configuration options (with sensitive data removed).
103
102
  #
104
- # @return [Hash] Provider information including :name, :class, :available, :options
103
+ # @return [Hash] Provider information including :name, :class, :available,
104
+ # and recursively sanitized :options
105
105
  def provider_info
106
106
  {
107
107
  name: @provider_name,
@@ -111,20 +111,35 @@ module Prescient
111
111
  }
112
112
  end
113
113
 
114
- def method_missing(method_name, ...)
115
- @provider.respond_to?(method_name) ? @provider.send(method_name, ...) : super
116
- end
114
+ private
115
+
116
+ def sanitize_options(options)
117
+ sensitive_keys = Prescient::Configuration::DEFAULT_SENSITIVE_KEYS + Prescient.configuration.sensitive_keys
118
+
119
+ case options
120
+ when Hash
121
+ sanitized = {} # : Hash[untyped, untyped]
122
+ options.each do |key, value|
123
+ next if key.respond_to?(:to_sym) && sensitive_keys.include?(key.to_sym)
117
124
 
118
- def respond_to_missing?(method_name, include_private = false)
119
- @provider.respond_to?(method_name, include_private) || super
125
+ sanitized[key] = sanitize_options(value)
126
+ end
127
+ sanitized
128
+ when Array
129
+ options.map { |value| sanitize_options(value) }
130
+ else
131
+ options
132
+ end
120
133
  end
121
134
 
122
- private
135
+ def provider_with_options(provider_name, provider_options)
136
+ return Prescient.configuration.provider(provider_name) if provider_options.empty?
123
137
 
124
- # TODO: configurable keys to sanitize
125
- def sanitize_options(options)
126
- sensitive_keys = [:api_key, :password, :token, :secret]
127
- options.reject { |key, _| sensitive_keys.include?(key.to_sym) }
138
+ registration = Prescient.configuration.providers[provider_name.to_sym]
139
+ return unless registration
140
+
141
+ registration_options = registration[:options] # : Hash[Symbol, untyped]
142
+ registration[:class].new(**registration_options, **provider_options)
128
143
  end
129
144
 
130
145
  def with_error_handling
@@ -150,24 +165,17 @@ module Prescient
150
165
  last_error = nil
151
166
 
152
167
  providers_to_try.each_with_index do |provider_name, index|
153
- # Use existing provider instance for primary provider, create new ones for fallbacks
154
- provider = if index.zero? && provider_name == @provider_name
155
- @provider
156
- else
157
- Prescient.configuration.provider(provider_name)
158
- end
168
+ provider = provider_for(provider_name, index)
159
169
  next unless provider
160
170
 
161
- # Check if provider is available before trying
162
- next unless provider.available?
163
-
164
- # Use retry logic for each provider
171
+ # Use the provider operation as the availability probe.
165
172
  return with_error_handling do
166
173
  provider.send(method_name, *args, **options)
167
174
  end
168
175
  rescue Prescient::Error => e
176
+ raise e unless fallback_eligible?(e)
177
+
169
178
  last_error = e
170
- # Log the error and continue to next provider
171
179
  next
172
180
  end
173
181
 
@@ -175,36 +183,74 @@ module Prescient
175
183
  raise last_error || Prescient::Error.new("No available providers for #{method_name}")
176
184
  end
177
185
 
186
+ def provider_for(provider_name, index)
187
+ return @provider if index.zero? && provider_name == @provider_name
188
+
189
+ Prescient.configuration.provider(provider_name)
190
+ end
191
+
178
192
  def providers_to_try
179
193
  providers = [@provider_name]
180
194
 
181
195
  # Add configured fallback providers
182
196
  fallback_providers = Prescient.configuration.fallback_providers
183
- if fallback_providers && !fallback_providers.empty?
184
- providers += fallback_providers.reject { |p| p == @provider_name }
185
- else
186
- # If no explicit fallbacks configured, try all available providers
187
- available = Prescient.configuration.available_providers
188
- providers += available.reject { |p| p == @provider_name }
189
- end
197
+ additional_providers = if fallback_providers && !fallback_providers.empty?
198
+ fallback_providers.reject { |p| p == @provider_name }
199
+ else
200
+ # If no explicit fallbacks are configured, probe all configured providers
201
+ Prescient.configuration.providers.keys.reject { |p| p == @provider_name }
202
+ end
203
+ providers += additional_providers
190
204
 
191
205
  providers.uniq
192
206
  end
207
+
208
+ def fallback_eligible?(error)
209
+ [
210
+ Prescient::ConnectionError,
211
+ Prescient::RateLimitError,
212
+ Prescient::ModelNotAvailableError,
213
+ Prescient::ProviderError,
214
+ ].any? { |error_class| error.is_a?(error_class) }
215
+ end
193
216
  end
194
217
 
195
218
  # Convenience methods for quick access
196
- def self.client(provider_name = nil, enable_fallback: true)
197
- Client.new(provider_name, enable_fallback: enable_fallback)
219
+ #
220
+ # @param provider_name [Symbol, nil] Provider to use, or the configured default
221
+ # @param enable_fallback [Boolean] Whether provider fallback is enabled
222
+ # @param provider_options [Hash] Temporary options for the selected provider
223
+ # @return [Client] A configured client instance
224
+ def self.client(provider_name = nil, enable_fallback: true, provider_options: {})
225
+ Client.new(provider_name, enable_fallback: enable_fallback, provider_options: provider_options)
198
226
  end
199
227
 
228
+ # Generate an embedding through a configured provider.
229
+ #
230
+ # @param text [String] Text to embed
231
+ # @param provider [Symbol, nil] Provider to use
232
+ # @param enable_fallback [Boolean] Whether provider fallback is enabled
233
+ # @return [Array<Float>] Embedding vector
200
234
  def self.generate_embedding(text, provider: nil, enable_fallback: true, **options)
201
235
  client(provider, enable_fallback: enable_fallback).generate_embedding(text, **options)
202
236
  end
203
237
 
238
+ # Generate a response through a configured provider.
239
+ #
240
+ # @param prompt [String] Prompt to send
241
+ # @param context_items [Array<Hash, String>] Optional context items
242
+ # @param provider [Symbol, nil] Provider to use
243
+ # @param enable_fallback [Boolean] Whether provider fallback is enabled
244
+ # @return [Hash] Normalized provider response with :response, :model, :provider
245
+ # and optional metadata
204
246
  def self.generate_response(prompt, context_items = [], provider: nil, enable_fallback: true, **options)
205
247
  client(provider, enable_fallback: enable_fallback).generate_response(prompt, context_items, **options)
206
248
  end
207
249
 
250
+ # Return the health status of a configured provider.
251
+ #
252
+ # @param provider [Symbol, nil] Provider to check
253
+ # @return [Hash] Provider health information
208
254
  def self.health_check(provider: nil)
209
255
  client(provider, enable_fallback: false).health_check
210
256
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prescient
4
+ # Base error class for all Prescient-specific errors
5
+ class Error < StandardError
6
+ attr_reader :provider
7
+ attr_reader :operation
8
+ attr_reader :status
9
+
10
+ def initialize(message = nil, provider: nil, operation: nil, status: nil)
11
+ super(message)
12
+
13
+ @provider = provider
14
+ @operation = operation
15
+ @status = status
16
+ end
17
+ end
18
+
19
+ # Raised when there are connection issues with AI providers
20
+ class ConnectionError < Error; end
21
+
22
+ # Raised when API authentication fails
23
+ class AuthenticationError < Error; end
24
+
25
+ # Raised when API rate limits are exceeded
26
+ class RateLimitError < Error; end
27
+
28
+ # Raised when a requested model is not available
29
+ class ModelNotAvailableError < Error; end
30
+
31
+ # Raised when AI provider returns invalid or malformed responses
32
+ class InvalidResponseError < Error; end
33
+
34
+ # Raised when a vector cannot be stored or searched safely
35
+ class InvalidVectorError < Error; end
36
+
37
+ # Raised when an AI provider reports a transient service-side failure
38
+ class ProviderError < Error; end
39
+
40
+ # Container module for AI provider implementations
41
+ #
42
+ # All provider classes should be defined within this module and inherit
43
+ # from {Prescient::Base}.
44
+ module Provider
45
+ # Module for AI provider implementations
46
+ end
47
+
48
+ # Namespace for optional PostgreSQL pgvector integration
49
+ module Pgvector
50
+ end
51
+ end