prescient 0.6.0 → 0.8.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +59 -0
  4. data/INTEGRATION_GUIDE.md +19 -1
  5. data/README.md +369 -21
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/docker-compose.yml +22 -0
  9. data/examples/README.md +36 -1
  10. data/examples/custom_contexts.rb +4 -4
  11. data/examples/web_search.rb +34 -0
  12. data/exe/prescient +2 -2
  13. data/exe/prescient-mcp +7 -0
  14. data/lib/prescient/agent/audit_log.rb +37 -0
  15. data/lib/prescient/agent/cli_adapter.rb +29 -0
  16. data/lib/prescient/agent/configuration.rb +57 -0
  17. data/lib/prescient/agent/context.rb +56 -0
  18. data/lib/prescient/agent/error_serializer.rb +47 -0
  19. data/lib/prescient/agent/errors.rb +25 -0
  20. data/lib/prescient/agent/parser.rb +49 -0
  21. data/lib/prescient/agent/prompt_builder.rb +31 -0
  22. data/lib/prescient/agent/result.rb +36 -0
  23. data/lib/prescient/agent/runtime.rb +175 -0
  24. data/lib/prescient/agent/schema_validator.rb +215 -0
  25. data/lib/prescient/agent/tool_registry.rb +89 -0
  26. data/lib/prescient/agent.rb +22 -0
  27. data/lib/prescient/api.rb +346 -231
  28. data/lib/prescient/base.rb +370 -372
  29. data/lib/prescient/cli.rb +591 -402
  30. data/lib/prescient/client.rb +31 -4
  31. data/lib/prescient/configuration_loader.rb +511 -336
  32. data/lib/prescient/document_source.rb +114 -0
  33. data/lib/prescient/errors.rb +13 -3
  34. data/lib/prescient/mcp/authentication.rb +39 -0
  35. data/lib/prescient/mcp/configuration.rb +38 -0
  36. data/lib/prescient/mcp/rack.rb +243 -0
  37. data/lib/prescient/mcp/server.rb +202 -0
  38. data/lib/prescient/mcp/stdio.rb +42 -0
  39. data/lib/prescient/mcp.rb +8 -0
  40. data/lib/prescient/pgvector.rb +193 -189
  41. data/lib/prescient/provider/anthropic.rb +129 -125
  42. data/lib/prescient/provider/deepseek.rb +122 -118
  43. data/lib/prescient/provider/gemini.rb +153 -149
  44. data/lib/prescient/provider/huggingface.rb +191 -187
  45. data/lib/prescient/provider/mistral.rb +151 -147
  46. data/lib/prescient/provider/ollama.rb +168 -165
  47. data/lib/prescient/provider/openai.rb +174 -171
  48. data/lib/prescient/provider/xai.rb +122 -118
  49. data/lib/prescient/tool/search_api.rb +130 -0
  50. data/lib/prescient/tool/searxng.rb +128 -0
  51. data/lib/prescient/tool.rb +125 -0
  52. data/lib/prescient/version.rb +1 -1
  53. data/lib/prescient.rb +129 -55
  54. data/schema/prescient.configuration.schema.json +119 -0
  55. data/searxng/settings.yml +18 -0
  56. data/sig/prescient.rbs +228 -1
  57. metadata +33 -5
data/lib/prescient/api.rb CHANGED
@@ -1,285 +1,400 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'securerandom'
5
- require 'stringio'
6
- require 'uri'
7
- require_relative '../prescient'
8
-
9
- # Dependency-free Rack-compatible HTTP application for Prescient operations.
10
- #
11
- # The application exposes only generic Prescient operations. It does not
12
- # expose provider-specific methods, credentials, or raw provider responses.
13
- class Prescient::API
14
- # @return [Integer] Default maximum request body size in bytes
15
- DEFAULT_MAX_BODY_BYTES = 1_048_576
16
- # @return [Integer] Maximum number of inputs accepted by batch embeddings
17
- MAX_BATCH_SIZE = 32
18
- # @return [String] HTTP API version
19
- API_VERSION = '1'
20
- # @return [Hash<Array<String>, Symbol>] Generic HTTP route handlers
21
- ROUTES = {
22
- ['GET', '/healthz'] => :healthz_response,
23
- ['GET', '/readyz'] => :readiness_response,
24
- ['GET', '/v1/version'] => :version_response,
25
- ['GET', '/v1/providers'] => :providers_response,
26
- ['GET', '/v1/models'] => :models_response,
27
- ['GET', '/v1/capabilities'] => :capabilities_response,
28
- ['GET', '/v1/health'] => :health_response,
29
- ['POST', '/v1/generate'] => :generate_response,
30
- ['POST', '/v1/embeddings'] => :embeddings_response,
31
- ['POST', '/v1/embeddings/batch'] => :batch_embeddings_response,
32
- }.freeze
33
-
34
- # @param authentication [#call, nil] Optional authentication hook
35
- # @param max_body_bytes [Integer] Maximum accepted request body size
36
- # @return [void]
37
- def initialize(authentication: nil, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
38
- @authentication = authentication
39
- @max_body_bytes = validate_body_limit(max_body_bytes)
40
- end
3
+ require "json"
4
+ require "securerandom"
5
+ require "stringio"
6
+ require "uri"
7
+ require_relative "../prescient"
8
+
9
+ module Prescient
10
+ # Dependency-free Rack-compatible HTTP application for Prescient operations.
11
+ #
12
+ # The application exposes only generic Prescient operations. It does not
13
+ # expose provider-specific methods, credentials, or raw provider responses.
14
+ # rubocop:disable Metrics/ClassLength
15
+ class API
16
+ # @return [Integer] Default maximum request body size in bytes
17
+ DEFAULT_MAX_BODY_BYTES = 1_048_576
18
+ # @return [Integer] Maximum number of inputs accepted by batch embeddings
19
+ MAX_BATCH_SIZE = 32
20
+ # @return [String] HTTP API version
21
+ API_VERSION = "1"
22
+ # @return [Hash<Array<String>, Symbol>] Generic HTTP route handlers
23
+ ROUTES = {
24
+ ["GET", "/healthz"] => :healthz_response,
25
+ ["GET", "/readyz"] => :readiness_response,
26
+ ["GET", "/v1/version"] => :version_response,
27
+ ["GET", "/v1/providers"] => :providers_response,
28
+ ["GET", "/v1/models"] => :models_response,
29
+ ["GET", "/v1/capabilities"] => :capabilities_response,
30
+ ["GET", "/v1/health"] => :health_response,
31
+ ["POST", "/v1/generate"] => :generate_response,
32
+ ["POST", "/v1/search"] => :search_response,
33
+ ["POST", "/v1/search/generate"] => :search_generate_response,
34
+ ["POST", "/v1/agent"] => :agent_response,
35
+ ["POST", "/v1/embeddings"] => :embeddings_response,
36
+ ["POST", "/v1/embeddings/batch"] => :batch_embeddings_response
37
+ }.freeze
38
+
39
+ # @param authentication [#call, nil] Optional authentication hook
40
+ # @param max_body_bytes [Integer] Maximum accepted request body size
41
+ # @return [void]
42
+ def initialize(authentication: nil, authorization: nil, request_context: nil,
43
+ telemetry: nil, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
44
+ @authentication = authentication
45
+ @authorization = authorization
46
+ @request_context = request_context
47
+ @telemetry = telemetry
48
+ @max_body_bytes = validate_body_limit(max_body_bytes)
49
+ end
41
50
 
42
- # Handle a Rack-style environment and return a Rack response tuple.
43
- # @param env [Hash] Rack-compatible request environment
44
- # @return [Array(Integer, Hash, Array<String>)] HTTP status, headers, body
45
- def call(env)
46
- request_id = request_id_for(env)
47
- public_path = request_target(env).first
48
- return dispatch(env, request_id) if ['/healthz', '/readyz'].include?(public_path)
51
+ # Handle a Rack-style environment and return a Rack response tuple.
52
+ # @param env [Hash] Rack-compatible request environment
53
+ # @return [Array(Integer, Hash, Array<String>)] HTTP status, headers, body
54
+ def call(env)
55
+ request_id = request_id_for(env)
56
+ public_path = request_target(env).first
57
+ return dispatch(env, request_id) if ["/healthz", "/readyz"].include?(public_path)
58
+
59
+ authentication = authentication_result(env)
60
+ unless authentication
61
+ return response(401,
62
+ error_payload("authentication_required", "authentication required",
63
+ request_id))
64
+ end
49
65
 
50
- unless authenticated?(env)
51
- return response(401,
52
- error_payload('authentication_required', 'authentication required',
53
- request_id))
66
+ dispatch(env, request_id, principal: authentication == true ? nil : authentication)
67
+ rescue StandardError => e
68
+ handle_exception(e, request_id)
54
69
  end
55
70
 
56
- dispatch(env, request_id)
57
- rescue StandardError => e
58
- handle_exception(e, request_id)
59
- end
71
+ private
60
72
 
61
- private
73
+ def dispatch(env, request_id, principal: nil)
74
+ method = env.fetch("REQUEST_METHOD", "GET").upcase
75
+ path, query = request_target(env)
76
+ handler = ROUTES[[method, path]]
77
+ return response(404, error_payload("not_found", "route not found", request_id)) unless handler
62
78
 
63
- def dispatch(env, request_id)
64
- method = env.fetch('REQUEST_METHOD', 'GET').upcase
65
- path, query = request_target(env)
66
- handler = ROUTES[[method, path]]
67
- return response(404, error_payload('not_found', 'route not found', request_id)) unless handler
79
+ return send(handler, env, query, request_id, principal) if handler == :agent_response
68
80
 
69
- send(handler, env, query, request_id)
70
- end
81
+ send(handler, env, query, request_id)
82
+ end
71
83
 
72
- def healthz_response(_env, _query, request_id)
73
- json_response(200, { status: 'ok' }, request_id)
74
- end
84
+ def healthz_response(_env, _query, request_id)
85
+ json_response(200, { status: "ok" }, request_id)
86
+ end
75
87
 
76
- def version_response(_env, _query, request_id)
77
- json_response(200, { version: Prescient::VERSION, api_version: API_VERSION }, request_id)
78
- end
88
+ def version_response(_env, _query, request_id)
89
+ json_response(200, { version: Prescient::VERSION, api_version: API_VERSION }, request_id)
90
+ end
91
+
92
+ def generate_response(env, _query, request_id)
93
+ payload = request_payload(env)
94
+ prompt = required_string(payload, "prompt")
95
+ context = if payload.key?("documents")
96
+ Prescient::DocumentSource::Memory.new(documents: payload["documents"]).fetch
97
+ else
98
+ payload.fetch("context", [])
99
+ end
100
+ raise ArgumentError, "context must be an array" unless context.is_a?(Array)
79
101
 
80
- def generate_response(env, _query, request_id)
81
- payload = request_payload(env)
82
- prompt = required_string(payload, 'prompt')
83
- context = payload.fetch('context', [])
84
- raise ArgumentError, 'context must be an array' unless context.is_a?(Array)
102
+ client = client_for(payload)
103
+ result = client.generate_response(prompt, context, **generation_options(payload))
104
+ json_response(200, result, request_id)
105
+ end
85
106
 
86
- client = client_for(payload)
87
- result = client.generate_response(prompt, context, **generation_options(payload))
88
- json_response(200, result, request_id)
89
- end
107
+ def search_generate_response(env, _query, request_id)
108
+ payload = request_payload(env)
109
+ query = required_string(payload, "query")
110
+ tool = search_tool_name(payload)
111
+ fallback = search_fallback(payload)
112
+ limit = search_limit(payload)
113
+
114
+ result = Prescient.search_and_generate(
115
+ query,
116
+ tool: tool,
117
+ provider: payload["provider"]&.to_sym,
118
+ limit: limit,
119
+ enable_fallback: fallback,
120
+ **generation_options(payload)
121
+ )
122
+ json_response(200, result, request_id)
123
+ end
90
124
 
91
- def embeddings_response(env, _query, request_id)
92
- payload = request_payload(env)
93
- input = required_string(payload, 'input')
94
- client = client_for(payload)
95
- result = client.generate_embedding(input, **model_options(payload))
96
- json_response(200, embedding_payload(result, client), request_id)
97
- end
125
+ def search_response(env, _query, request_id)
126
+ payload = request_payload(env)
127
+ query = required_string(payload, "query")
128
+ tool_name = search_tool_name(payload)
129
+ tool = Prescient.tool(tool_name)
130
+ raise Prescient::ToolConfigurationError, "tool not configured: #{tool_name}" unless tool
98
131
 
99
- def batch_embeddings_response(env, _query, request_id)
100
- payload = request_payload(env)
101
- inputs = payload['inputs']
102
- raise ArgumentError, 'inputs must be a non-empty array' unless inputs.is_a?(Array) && inputs.any?
103
- raise ArgumentError, "inputs cannot contain more than #{MAX_BATCH_SIZE} items" if inputs.length > MAX_BATCH_SIZE
104
- raise ArgumentError, 'inputs must contain only strings' unless inputs.all?(String)
105
-
106
- client = client_for(payload)
107
- embeddings = inputs.map { |input| client.generate_embedding(input, **model_options(payload)) }
108
- result = { embeddings: embeddings, dimensions: embeddings.first.length, provider: client.provider_name.to_s }
109
- json_response(200,
110
- result, request_id)
111
- end
132
+ result = tool.search(query, limit: search_limit(payload))
133
+ json_response(200, result, request_id)
134
+ end
135
+
136
+ def agent_response(env, _query, request_id, principal)
137
+ require "prescient/agent"
138
+ payload = request_payload(env)
139
+ prompt = required_string(payload, "prompt")
140
+ json_response(200, agent_runtime(payload, env, request_id, principal:).run(prompt).to_h, request_id)
141
+ end
142
+
143
+ def agent_runtime(payload, env, request_id, principal: nil)
144
+ tools = payload.fetch("tools", [])
145
+ validate_agent_tools(tools)
146
+ configuration = Prescient::Agent::Configuration.new(max_loops: payload.fetch("max_loops", 5))
147
+ Prescient::Agent::Runtime.new(
148
+ provider: payload["provider"]&.to_sym,
149
+ tool_names: tools,
150
+ configuration: configuration,
151
+ authorization: @authorization,
152
+ telemetry: @telemetry,
153
+ enable_fallback: agent_fallback(payload),
154
+ generation_options: model_options(payload),
155
+ request_context: request_context(env, request_id, principal:)
156
+ )
157
+ end
158
+
159
+ def agent_fallback(payload)
160
+ fallback = payload.fetch("fallback", true)
161
+ raise ArgumentError, "fallback must be boolean" unless [true, false].include?(fallback)
112
162
 
113
- def readiness_response(_env, _query, request_id)
114
- providers = Prescient.configuration.providers.keys
115
- ready = providers.any? { |name|
116
- begin
163
+ fallback
164
+ end
165
+
166
+ def validate_agent_tools(tools)
167
+ valid = tools.is_a?(Array) && tools.all? { |name| name.is_a?(String) && !name.empty? }
168
+ raise ArgumentError, "tools must be an array of names" unless valid
169
+ end
170
+
171
+ def search_tool_name(payload)
172
+ value = payload.fetch("tool", "web_search")
173
+ raise ArgumentError, "tool must be a non-empty string" unless value.is_a?(String) && !value.empty?
174
+
175
+ value.to_sym
176
+ end
177
+
178
+ def search_fallback(payload)
179
+ fallback = payload.key?("fallback") ? payload["fallback"] : true
180
+ raise ArgumentError, "fallback must be boolean" unless [true, false].include?(fallback)
181
+
182
+ fallback
183
+ end
184
+
185
+ def search_limit(payload)
186
+ limit = payload["limit"]
187
+ raise ArgumentError, "limit must be a positive integer" if limit && (!limit.is_a?(Integer) || !limit.positive?)
188
+
189
+ limit
190
+ end
191
+
192
+ def embeddings_response(env, _query, request_id)
193
+ payload = request_payload(env)
194
+ input = required_string(payload, "input")
195
+ client = client_for(payload)
196
+ result = client.generate_embedding(input, **model_options(payload))
197
+ json_response(200, embedding_payload(result, client), request_id)
198
+ end
199
+
200
+ def batch_embeddings_response(env, _query, request_id)
201
+ payload = request_payload(env)
202
+ inputs = payload["inputs"]
203
+ raise ArgumentError, "inputs must be a non-empty array" unless inputs.is_a?(Array) && inputs.any?
204
+ raise ArgumentError, "inputs cannot contain more than #{MAX_BATCH_SIZE} items" if inputs.length > MAX_BATCH_SIZE
205
+ raise ArgumentError, "inputs must contain only strings" unless inputs.all?(String)
206
+
207
+ client = client_for(payload)
208
+ embeddings = inputs.map { |input| client.generate_embedding(input, **model_options(payload)) }
209
+ result = { embeddings: embeddings, dimensions: embeddings.first.length, provider: client.provider_name.to_s }
210
+ json_response(200,
211
+ result, request_id)
212
+ end
213
+
214
+ def readiness_response(_env, _query, request_id)
215
+ providers = Prescient.configuration.providers.keys
216
+ ready = providers.any? do |name|
117
217
  Prescient.health_check(provider: name)[:ready] == true
118
218
  rescue Prescient::Error
119
219
  false
120
220
  end
121
- }
122
- json_response(ready ? 200 : 503, { status: ready ? 'ready' : 'not_ready' }, request_id)
123
- end
221
+ json_response(ready ? 200 : 503, { status: ready ? "ready" : "not_ready" }, request_id)
222
+ end
124
223
 
125
- def providers_response(_env, _query, request_id)
126
- providers = Prescient.configuration.providers.map { |name, registration|
127
- { name: name.to_s, class: registration[:class].name }
128
- }
129
- json_response(200, { providers: providers }, request_id)
130
- end
224
+ def providers_response(_env, _query, request_id)
225
+ providers = Prescient.configuration.providers.map do |name, registration|
226
+ { name: name.to_s, class: registration[:class].name }
227
+ end
228
+ json_response(200, { providers: providers }, request_id)
229
+ end
131
230
 
132
- def models_response(_env, query, request_id)
133
- names = query['provider'] ? [query['provider'].to_sym] : Prescient.configuration.providers.keys
134
- models = names.flat_map { |name|
135
- provider = Prescient.configuration.provider(name)
136
- raise Prescient::Error, "Provider not configured: #{name}" unless provider
231
+ def models_response(_env, query, request_id)
232
+ names = query["provider"] ? [query["provider"].to_sym] : Prescient.configuration.providers.keys
233
+ models = names.flat_map do |name|
234
+ provider = Prescient.configuration.provider(name)
235
+ raise Prescient::Error, "Provider not configured: #{name}" unless provider
236
+
237
+ records = if provider.respond_to?(:list_models)
238
+ provider.list_models
239
+ elsif provider.respond_to?(:available_models)
240
+ provider.available_models
241
+ else
242
+ []
243
+ end
244
+ records.map { |model| { provider: name.to_s, model: model } }
245
+ end
246
+ json_response(200, { models: models }, request_id)
247
+ end
137
248
 
138
- records = if provider.respond_to?(:list_models)
139
- provider.list_models
140
- elsif provider.respond_to?(:available_models)
141
- provider.available_models
142
- else
143
- []
144
- end
145
- records.map { |model| { provider: name.to_s, model: model } }
146
- }
147
- json_response(200, { models: models }, request_id)
148
- end
249
+ def capabilities_response(_env, _query, request_id)
250
+ capabilities = Prescient.configuration.providers.map do |name, registration|
251
+ provider = registration[:class]
252
+ {
253
+ provider: name.to_s,
254
+ generation: provider.method_defined?(:generate_response),
255
+ embeddings: provider.method_defined?(:generate_embedding),
256
+ health: provider.method_defined?(:health_check),
257
+ model_listing: provider.method_defined?(:list_models) || provider.method_defined?(:available_models)
258
+ }
259
+ end
260
+ json_response(200, { capabilities: capabilities }, request_id)
261
+ end
149
262
 
150
- def capabilities_response(_env, _query, request_id)
151
- capabilities = Prescient.configuration.providers.map { |name, registration|
152
- provider = registration[:class]
153
- {
154
- provider: name.to_s,
155
- generation: provider.method_defined?(:generate_response),
156
- embeddings: provider.method_defined?(:generate_embedding),
157
- health: provider.method_defined?(:health_check),
158
- model_listing: provider.method_defined?(:list_models) || provider.method_defined?(:available_models),
159
- }
160
- }
161
- json_response(200, { capabilities: capabilities }, request_id)
162
- end
263
+ def health_response(_env, query, request_id)
264
+ if query["provider"]
265
+ json_response(200, Prescient.health_check(provider: query["provider"].to_sym), request_id)
266
+ else
267
+ results = Prescient.configuration.providers.keys.to_h do |name|
268
+ [name.to_s, Prescient.health_check(provider: name)]
269
+ end
270
+ json_response(200, results, request_id)
271
+ end
272
+ end
163
273
 
164
- def health_response(_env, query, request_id)
165
- if query['provider']
166
- json_response(200, Prescient.health_check(provider: query['provider'].to_sym), request_id)
167
- else
168
- results = Prescient.configuration.providers.keys.to_h { |name|
169
- [name.to_s, Prescient.health_check(provider: name)]
170
- }
171
- json_response(200, results, request_id)
274
+ def client_for(payload)
275
+ provider = payload["provider"]&.to_sym
276
+ fallback = payload.key?("fallback") ? payload["fallback"] : true
277
+ raise ArgumentError, "fallback must be boolean" unless [true, false].include?(fallback)
278
+
279
+ Prescient.client(provider, enable_fallback: fallback)
172
280
  end
173
- end
174
281
 
175
- def client_for(payload)
176
- provider = payload['provider']&.to_sym
177
- fallback = payload.key?('fallback') ? payload['fallback'] : true
178
- raise ArgumentError, 'fallback must be boolean' unless [true, false].include?(fallback)
282
+ def generation_options(payload)
283
+ options = model_options(payload)
284
+ %w[temperature max_tokens top_p].each do |key|
285
+ options[key.to_sym] = payload[key] if payload.key?(key)
286
+ end
287
+ options
288
+ end
179
289
 
180
- Prescient.client(provider, enable_fallback: fallback)
181
- end
290
+ def model_options(payload)
291
+ payload["model"] ? { model: payload["model"] } : {}
292
+ end
182
293
 
183
- def generation_options(payload)
184
- options = model_options(payload)
185
- ['temperature', 'max_tokens', 'top_p'].each do |key|
186
- options[key.to_sym] = payload[key] if payload.key?(key)
294
+ def embedding_payload(embedding, client)
295
+ { embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s }
187
296
  end
188
- options
189
- end
190
297
 
191
- def model_options(payload)
192
- payload['model'] ? { model: payload['model'] } : {}
193
- end
298
+ def request_payload(env)
299
+ content_length = env["CONTENT_LENGTH"].to_i
300
+ raise ArgumentError, "request body exceeds configured limit" if content_length > @max_body_bytes
194
301
 
195
- def embedding_payload(embedding, client)
196
- { embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s }
197
- end
302
+ body = env.fetch("rack.input", StringIO.new).read(@max_body_bytes + 1)
303
+ raise ArgumentError, "request body exceeds configured limit" if body.bytesize > @max_body_bytes
198
304
 
199
- def request_payload(env)
200
- content_length = env['CONTENT_LENGTH'].to_i
201
- raise ArgumentError, 'request body exceeds configured limit' if content_length > @max_body_bytes
305
+ parsed = JSON.parse(body)
306
+ raise ArgumentError, "request body must contain a JSON object" unless parsed.is_a?(Hash)
202
307
 
203
- body = env.fetch('rack.input', StringIO.new).read(@max_body_bytes + 1)
204
- raise ArgumentError, 'request body exceeds configured limit' if body.bytesize > @max_body_bytes
308
+ parsed
309
+ end
205
310
 
206
- parsed = JSON.parse(body)
207
- raise ArgumentError, 'request body must contain a JSON object' unless parsed.is_a?(Hash)
311
+ def required_string(payload, key)
312
+ value = payload[key]
313
+ raise ArgumentError, "#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
208
314
 
209
- parsed
210
- end
315
+ value
316
+ end
211
317
 
212
- def required_string(payload, key)
213
- value = payload[key]
214
- raise ArgumentError, "#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
318
+ def request_target(env)
319
+ target = env["REQUEST_URI"] || env["PATH_INFO"] || "/"
320
+ path, query = target.split("?", 2)
321
+ [path, URI.decode_www_form(query.to_s).to_h]
322
+ end
215
323
 
216
- value
217
- end
324
+ def authentication_result(env)
325
+ return true unless @authentication
218
326
 
219
- def request_target(env)
220
- target = env['REQUEST_URI'] || env['PATH_INFO'] || '/'
221
- path, query = target.split('?', 2)
222
- [path, URI.decode_www_form(query.to_s).to_h]
223
- end
327
+ @authentication.call(env)
328
+ end
224
329
 
225
- def authenticated?(env)
226
- return true unless @authentication
330
+ def request_context(env, request_id, principal: nil)
331
+ base = {
332
+ request_id: request_id,
333
+ tenant_id: env["HTTP_X_TENANT_ID"],
334
+ principal: principal || env["REMOTE_USER"]
335
+ }.compact
336
+ return base unless @request_context
227
337
 
228
- @authentication.call(env) == true
229
- end
338
+ resolved = @request_context.call(env)
339
+ raise ArgumentError, "request context hook must return a mapping" unless resolved.is_a?(Hash)
230
340
 
231
- def request_id_for(env)
232
- supplied = env['HTTP_X_REQUEST_ID'].to_s
233
- supplied.match?(/\A[a-zA-Z0-9._:-]{1,128}\z/) ? supplied : SecureRandom.uuid
234
- end
341
+ base.merge(resolved)
342
+ end
235
343
 
236
- def json_response(status, payload, request_id)
237
- response(status, payload.merge(request_id: request_id))
238
- end
344
+ def request_id_for(env)
345
+ supplied = env["HTTP_X_REQUEST_ID"].to_s
346
+ supplied.match?(/\A[a-zA-Z0-9._:-]{1,128}\z/) ? supplied : SecureRandom.uuid
347
+ end
239
348
 
240
- def response(status, payload)
241
- body = JSON.generate(payload)
242
- headers = {
243
- 'content-type' => 'application/json',
244
- 'content-length' => body.bytesize.to_s,
245
- }
246
- headers['x-request-id'] = payload[:request_id] if payload[:request_id]
247
- [status, headers, [body]]
248
- end
349
+ def json_response(status, payload, request_id)
350
+ response(status, payload.merge(request_id: request_id))
351
+ end
249
352
 
250
- def error_payload(type, message, request_id)
251
- { error: { type: type, message: message }, request_id: request_id }
252
- end
353
+ def response(status, payload)
354
+ body = JSON.generate(payload)
355
+ headers = {
356
+ "content-type" => "application/json",
357
+ "content-length" => body.bytesize.to_s
358
+ }
359
+ headers["x-request-id"] = payload[:request_id] if payload[:request_id]
360
+ [status, headers, [body]]
361
+ end
253
362
 
254
- def error_type(error)
255
- error.class.name.split('::').last.delete_suffix('Error').downcase
256
- end
363
+ def error_payload(type, message, request_id)
364
+ { error: { type: type, message: message }, request_id: request_id }
365
+ end
257
366
 
258
- def error_status(error)
259
- return 401 if error.is_a?(Prescient::AuthenticationError)
260
- return 429 if error.is_a?(Prescient::RateLimitError)
261
- return 503 if error.is_a?(Prescient::ConnectionError) || error.is_a?(Prescient::ProviderError)
262
- return 422 if error.is_a?(Prescient::ModelNotAvailableError)
367
+ def error_type(error)
368
+ error.class.name.split("::").last.delete_suffix("Error").downcase
369
+ end
263
370
 
264
- 500
265
- end
371
+ def error_status(error)
372
+ return 401 if error.is_a?(Prescient::AuthenticationError)
373
+ return 429 if error.is_a?(Prescient::RateLimitError)
374
+ return 503 if error.is_a?(Prescient::ConnectionError) || error.is_a?(Prescient::ProviderError)
375
+ return 422 if error.is_a?(Prescient::ModelNotAvailableError)
266
376
 
267
- def handle_exception(error, request_id)
268
- case error
269
- when JSON::ParserError
270
- response(400, error_payload('invalid_json', 'request body must contain valid JSON', request_id))
271
- when ArgumentError
272
- response(400, error_payload('invalid_request', error.message, request_id))
273
- when Prescient::Error
274
- response(error_status(error), error_payload(error_type(error), error.message, request_id))
275
- else
276
- response(500, error_payload('internal_error', 'internal server error', request_id))
377
+ 500
277
378
  end
278
- end
279
379
 
280
- def validate_body_limit(value)
281
- return value if value.is_a?(Integer) && value.positive?
380
+ def handle_exception(error, request_id)
381
+ case error
382
+ when JSON::ParserError
383
+ response(400, error_payload("invalid_json", "request body must contain valid JSON", request_id))
384
+ when ArgumentError
385
+ response(400, error_payload("invalid_request", error.message, request_id))
386
+ when Prescient::Error
387
+ response(error_status(error), error_payload(error_type(error), error.message, request_id))
388
+ else
389
+ response(500, error_payload("internal_error", "internal server error", request_id))
390
+ end
391
+ end
282
392
 
283
- raise ArgumentError, 'max_body_bytes must be a positive integer'
393
+ def validate_body_limit(value)
394
+ return value if value.is_a?(Integer) && value.positive?
395
+
396
+ raise ArgumentError, "max_body_bytes must be a positive integer"
397
+ end
284
398
  end
399
+ # rubocop:enable Metrics/ClassLength
285
400
  end