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.
- checksums.yaml +4 -4
- data/.rubocop.yml +21 -268
- data/CHANGELOG.md +37 -0
- data/INTEGRATION_GUIDE.md +7 -1
- data/README.md +210 -1
- data/Steepfile +12 -12
- data/db/migrate/001_create_prescient_tables.rb +15 -16
- data/examples/README.md +2 -1
- data/examples/custom_contexts.rb +4 -4
- data/exe/prescient +2 -2
- data/exe/prescient-mcp +7 -0
- data/lib/prescient/agent/audit_log.rb +37 -0
- data/lib/prescient/agent/cli_adapter.rb +29 -0
- data/lib/prescient/agent/configuration.rb +57 -0
- data/lib/prescient/agent/context.rb +56 -0
- data/lib/prescient/agent/error_serializer.rb +47 -0
- data/lib/prescient/agent/errors.rb +25 -0
- data/lib/prescient/agent/parser.rb +49 -0
- data/lib/prescient/agent/prompt_builder.rb +31 -0
- data/lib/prescient/agent/result.rb +36 -0
- data/lib/prescient/agent/runtime.rb +175 -0
- data/lib/prescient/agent/schema_validator.rb +215 -0
- data/lib/prescient/agent/tool_registry.rb +89 -0
- data/lib/prescient/agent.rb +22 -0
- data/lib/prescient/api.rb +337 -274
- data/lib/prescient/base.rb +370 -372
- data/lib/prescient/cli.rb +586 -526
- data/lib/prescient/client.rb +7 -6
- data/lib/prescient/configuration_loader.rb +492 -488
- data/lib/prescient/document_source.rb +114 -0
- data/lib/prescient/errors.rb +1 -3
- data/lib/prescient/mcp/authentication.rb +39 -0
- data/lib/prescient/mcp/configuration.rb +38 -0
- data/lib/prescient/mcp/rack.rb +243 -0
- data/lib/prescient/mcp/server.rb +202 -0
- data/lib/prescient/mcp/stdio.rb +42 -0
- data/lib/prescient/mcp.rb +8 -0
- data/lib/prescient/pgvector.rb +193 -189
- data/lib/prescient/provider/anthropic.rb +129 -125
- data/lib/prescient/provider/deepseek.rb +122 -118
- data/lib/prescient/provider/gemini.rb +153 -149
- data/lib/prescient/provider/huggingface.rb +191 -187
- data/lib/prescient/provider/mistral.rb +151 -147
- data/lib/prescient/provider/ollama.rb +168 -165
- data/lib/prescient/provider/openai.rb +174 -169
- data/lib/prescient/provider/xai.rb +122 -118
- data/lib/prescient/tool/search_api.rb +125 -121
- data/lib/prescient/tool/searxng.rb +123 -119
- data/lib/prescient/tool.rb +100 -98
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +68 -62
- data/sig/prescient.rbs +176 -1
- metadata +23 -1
data/lib/prescient/api.rb
CHANGED
|
@@ -1,337 +1,400 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
7
|
-
require_relative
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
[
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@
|
|
41
|
-
@
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
rescue StandardError => e
|
|
60
|
-
handle_exception(e, request_id)
|
|
61
|
-
end
|
|
71
|
+
private
|
|
62
72
|
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
81
|
+
send(handler, env, query, request_id)
|
|
82
|
+
end
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
def healthz_response(_env, _query, request_id)
|
|
85
|
+
json_response(200, { status: "ok" }, request_id)
|
|
86
|
+
end
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
132
|
+
result = tool.search(query, limit: search_limit(payload))
|
|
133
|
+
json_response(200, result, request_id)
|
|
134
|
+
end
|
|
121
135
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
163
|
+
fallback
|
|
164
|
+
end
|
|
135
165
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
175
|
-
end
|
|
221
|
+
json_response(ready ? 200 : 503, { status: ready ? "ready" : "not_ready" }, request_id)
|
|
222
|
+
end
|
|
176
223
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
290
|
+
def model_options(payload)
|
|
291
|
+
payload["model"] ? { model: payload["model"] } : {}
|
|
292
|
+
end
|
|
234
293
|
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
308
|
+
parsed
|
|
309
|
+
end
|
|
257
310
|
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
327
|
+
@authentication.call(env)
|
|
328
|
+
end
|
|
270
329
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
338
|
+
resolved = @request_context.call(env)
|
|
339
|
+
raise ArgumentError, "request context hook must return a mapping" unless resolved.is_a?(Hash)
|
|
279
340
|
|
|
280
|
-
|
|
281
|
-
|
|
341
|
+
base.merge(resolved)
|
|
342
|
+
end
|
|
282
343
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
349
|
+
def json_response(status, payload, request_id)
|
|
350
|
+
response(status, payload.merge(request_id: request_id))
|
|
351
|
+
end
|
|
291
352
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
363
|
+
def error_payload(type, message, request_id)
|
|
364
|
+
{ error: { type: type, message: message }, request_id: request_id }
|
|
365
|
+
end
|
|
305
366
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
367
|
+
def error_type(error)
|
|
368
|
+
error.class.name.split("::").last.delete_suffix("Error").downcase
|
|
369
|
+
end
|
|
309
370
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
377
|
+
500
|
|
378
|
+
end
|
|
318
379
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
333
|
-
|
|
393
|
+
def validate_body_limit(value)
|
|
394
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
334
395
|
|
|
335
|
-
|
|
396
|
+
raise ArgumentError, "max_body_bytes must be a positive integer"
|
|
397
|
+
end
|
|
336
398
|
end
|
|
399
|
+
# rubocop:enable Metrics/ClassLength
|
|
337
400
|
end
|