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