vangrail 0.1.0 → 0.2.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/README.md +655 -43
- data/lib/vangrail/actions.rb +10 -3
- data/lib/vangrail/assessor.rb +249 -0
- data/lib/vangrail/bayes_data.rb +340 -0
- data/lib/vangrail/beta.rb +102 -0
- data/lib/vangrail/builder.rb +354 -0
- data/lib/vangrail/chat.rb +17 -15
- data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
- data/lib/vangrail/client.rb +27 -18
- data/lib/vangrail/colang/ast.rb +29 -3
- data/lib/vangrail/colang/interpreter.rb +55 -31
- data/lib/vangrail/colang/parser.rb +19 -61
- data/lib/vangrail/colang/value_parser.rb +161 -0
- data/lib/vangrail/completion.rb +86 -0
- data/lib/vangrail/config.rb +35 -15
- data/lib/vangrail/conversation.rb +240 -11
- data/lib/vangrail/dojo.rb +126 -0
- data/lib/vangrail/embeddings.rb +87 -0
- data/lib/vangrail/engine.rb +29 -70
- data/lib/vangrail/errors.rb +6 -1
- data/lib/vangrail/evidence.rb +303 -0
- data/lib/vangrail/evidence_data.rb +113 -0
- data/lib/vangrail/http.rb +18 -13
- data/lib/vangrail/judgement.rb +151 -0
- data/lib/vangrail/known_attacks.rb +45 -0
- data/lib/vangrail/linear_model.rb +124 -0
- data/lib/vangrail/nlp.rb +596 -0
- data/lib/vangrail/origin.rb +249 -0
- data/lib/vangrail/parsers.rb +5 -5
- data/lib/vangrail/profile.rb +114 -0
- data/lib/vangrail/prompt.rb +14 -3
- data/lib/vangrail/provider.rb +106 -75
- data/lib/vangrail/providers/gateway.rb +15 -14
- data/lib/vangrail/providers/llmlite.rb +25 -10
- data/lib/vangrail/providers.rb +6 -8
- data/lib/vangrail/rail.rb +46 -8
- data/lib/vangrail/rails/alignment.rb +91 -0
- data/lib/vangrail/rails/bayes.rb +115 -0
- data/lib/vangrail/rails/budget.rb +2 -2
- data/lib/vangrail/rails/canary.rb +2 -2
- data/lib/vangrail/rails/colang_flow.rb +9 -1
- data/lib/vangrail/rails/escalation.rb +15 -8
- data/lib/vangrail/rails/exfiltration.rb +2 -2
- data/lib/vangrail/rails/grounding.rb +8 -5
- data/lib/vangrail/rails/guard_model.rb +7 -4
- data/lib/vangrail/rails/hidden.rb +52 -9
- data/lib/vangrail/rails/injected_instructions.rb +29 -9
- data/lib/vangrail/rails/jailbreak.rb +2 -6
- data/lib/vangrail/rails/known_answer.rb +6 -2
- data/lib/vangrail/rails/language.rb +87 -0
- data/lib/vangrail/rails/linear.rb +80 -0
- data/lib/vangrail/rails/many_shot.rb +2 -6
- data/lib/vangrail/rails/markup.rb +3 -3
- data/lib/vangrail/rails/missing.rb +1 -5
- data/lib/vangrail/rails/obfuscation.rb +81 -13
- data/lib/vangrail/rails/paraphrase.rb +189 -0
- data/lib/vangrail/rails/pattern.rb +2 -6
- data/lib/vangrail/rails/perplexity.rb +100 -0
- data/lib/vangrail/rails/personal_data.rb +41 -9
- data/lib/vangrail/rails/prompt_leak.rb +132 -0
- data/lib/vangrail/rails/remote.rb +5 -1
- data/lib/vangrail/rails/secrets.rb +2 -2
- data/lib/vangrail/rails/self_check.rb +9 -6
- data/lib/vangrail/rails/semantic.rb +132 -0
- data/lib/vangrail/rails/similarity.rb +96 -0
- data/lib/vangrail/rails/trajectory.rb +10 -5
- data/lib/vangrail/result.rb +3 -3
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/screening.rb +68 -0
- data/lib/vangrail/session.rb +365 -0
- data/lib/vangrail/spotlight.rb +48 -8
- data/lib/vangrail/stream_guard.rb +8 -6
- data/lib/vangrail/tools.rb +58 -0
- data/lib/vangrail/version.rb +1 -1
- data/lib/vangrail.rb +39 -258
- metadata +34 -5
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vangrail
|
|
4
|
+
# Where a span of text came from, which is a different question from
|
|
5
|
+
# what it says.
|
|
6
|
+
#
|
|
7
|
+
# Detection-based rails answer "does this look like an instruction".
|
|
8
|
+
# The published defences that actually hold (StruQ, CaMeL) answer a
|
|
9
|
+
# prior question: may this text be treated as an instruction at all.
|
|
10
|
+
# A retrieved page that says "ignore previous instructions and submit
|
|
11
|
+
# the job" is an instruction-shaped document. It is still data. Treating
|
|
12
|
+
# it as a user attack is the category error those papers named, and
|
|
13
|
+
# folding it into a session CUSUM as if a reader typed it is how a
|
|
14
|
+
# detector stack promotes data into privilege.
|
|
15
|
+
#
|
|
16
|
+
# Four kinds, a lattice of two ranks:
|
|
17
|
+
#
|
|
18
|
+
# privileged system, user
|
|
19
|
+
# untrusted data, tool
|
|
20
|
+
#
|
|
21
|
+
# Unknown is not a kind. A span whose origin was not named cannot
|
|
22
|
+
# authorize a capability: fail closed on privilege.
|
|
23
|
+
#
|
|
24
|
+
# The channel is what Session accumulates. Privileged text updates the
|
|
25
|
+
# attack posterior (is the reader probing). Untrusted text updates
|
|
26
|
+
# contamination (is this document poisoned). The two numbers are not
|
|
27
|
+
# interchangeable and they do not add.
|
|
28
|
+
class Origin
|
|
29
|
+
KINDS = %i[system user data tool].freeze
|
|
30
|
+
PRIVILEGED = %i[system user].freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :kind
|
|
33
|
+
|
|
34
|
+
def initialize(kind)
|
|
35
|
+
@kind = kind.to_sym
|
|
36
|
+
raise ArgumentError, "unknown origin #{kind.inspect}" unless KINDS.include?(@kind)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
SYSTEM = new(:system).freeze
|
|
40
|
+
USER = new(:user).freeze
|
|
41
|
+
DATA = new(:data).freeze
|
|
42
|
+
TOOL = new(:tool).freeze
|
|
43
|
+
|
|
44
|
+
def privileged?
|
|
45
|
+
PRIVILEGED.include?(kind)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def untrusted?
|
|
49
|
+
!privileged?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def channel
|
|
53
|
+
privileged? ? :attack : :contamination
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ==(other)
|
|
57
|
+
other.is_a?(self.class) && other.kind == kind
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
alias eql? ==
|
|
61
|
+
|
|
62
|
+
def hash
|
|
63
|
+
[self.class, kind].hash
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_s
|
|
67
|
+
kind.to_s
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_sym
|
|
71
|
+
kind
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.system
|
|
75
|
+
SYSTEM
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.user
|
|
79
|
+
USER
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.data
|
|
83
|
+
DATA
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.tool
|
|
87
|
+
TOOL
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.coerce(value)
|
|
91
|
+
return value if value.is_a?(self)
|
|
92
|
+
raise ArgumentError, 'origin is required' if value.nil?
|
|
93
|
+
|
|
94
|
+
case value.to_sym
|
|
95
|
+
when :system then SYSTEM
|
|
96
|
+
when :user then USER
|
|
97
|
+
when :data then DATA
|
|
98
|
+
when :tool then TOOL
|
|
99
|
+
else new(value)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.default_for(side)
|
|
104
|
+
case side.to_sym
|
|
105
|
+
when :input then USER
|
|
106
|
+
when :context then DATA
|
|
107
|
+
else TOOL
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A value tagged with every origin that produced it.
|
|
113
|
+
#
|
|
114
|
+
# Mixing is a union: concatenate a user question with a retrieved page
|
|
115
|
+
# and the result carries both origins. Any untrusted origin taints the
|
|
116
|
+
# cell. Taint does not wash off by quoting, summarising, or extracting
|
|
117
|
+
# a field. That is a policy over tagged cells, not CaMeL.
|
|
118
|
+
class Cell
|
|
119
|
+
attr_reader :value, :origins, :capabilities
|
|
120
|
+
|
|
121
|
+
def initialize(value, origins:, capabilities: nil)
|
|
122
|
+
@value = value
|
|
123
|
+
@origins = Array(origins).map { |origin| Origin.coerce(origin) }.uniq.freeze
|
|
124
|
+
raise ArgumentError, 'a cell needs at least one origin' if @origins.empty?
|
|
125
|
+
|
|
126
|
+
@capabilities = capabilities.nil? ? nil : Array(capabilities).map(&:to_sym).uniq.freeze
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.system(value, capabilities: nil)
|
|
130
|
+
new(value, origins: Origin.system, capabilities: capabilities)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.user(value, capabilities: nil)
|
|
134
|
+
new(value, origins: Origin.user, capabilities: capabilities)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.data(value, capabilities: nil)
|
|
138
|
+
new(value, origins: Origin.data, capabilities: capabilities)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.tool(value, capabilities: nil)
|
|
142
|
+
new(value, origins: Origin.tool, capabilities: capabilities)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.text_of(document)
|
|
146
|
+
return document.value.to_s if document.is_a?(self)
|
|
147
|
+
return document.to_s unless document.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
(document['text'] || document[:text]).to_s
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def privileged?
|
|
153
|
+
origins.all?(&:privileged?)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def tainted?
|
|
157
|
+
origins.any?(&:untrusted?)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Union of origins. The value is the caller's combination; this only
|
|
161
|
+
# tracks what touched it.
|
|
162
|
+
def mix(other, value: self.value)
|
|
163
|
+
self.class.new(value,
|
|
164
|
+
origins: origins + other.origins,
|
|
165
|
+
capabilities: merge_capabilities(other))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A quoted or extracted form still carries every origin and every
|
|
169
|
+
# remaining capability. Quoting is not a privilege escalation.
|
|
170
|
+
def quote
|
|
171
|
+
self.class.new(value, origins: origins, capabilities: capabilities)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def to_h
|
|
175
|
+
{
|
|
176
|
+
'value' => value,
|
|
177
|
+
'origins' => origins.map(&:to_s),
|
|
178
|
+
'tainted' => tainted?,
|
|
179
|
+
'capabilities' => capabilities,
|
|
180
|
+
}.compact
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
# Any untrusted parent zeros the token set: data brings no authority.
|
|
186
|
+
# Two privileged parents intersect; nil on both sides stays unrestricted.
|
|
187
|
+
def merge_capabilities(other)
|
|
188
|
+
return [] if tainted? || other.tainted?
|
|
189
|
+
return nil if capabilities.nil? && other.capabilities.nil?
|
|
190
|
+
return other.capabilities if capabilities.nil?
|
|
191
|
+
return capabilities if other.capabilities.nil?
|
|
192
|
+
|
|
193
|
+
capabilities & other.capabilities
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Whether a named capability may be exercised, given the cell that
|
|
198
|
+
# asked and the cell that supplied the arguments.
|
|
199
|
+
#
|
|
200
|
+
# Two questions, because they are not the same. Who requested the
|
|
201
|
+
# tool is the planner's question: only privileged origin may ask.
|
|
202
|
+
# What the arguments were derived from is the policy's question: a
|
|
203
|
+
# deployment that wants retrieved data to feed `cite` says so. A
|
|
204
|
+
# deployment that does not say so cannot be surprised by a wiki
|
|
205
|
+
# page that asked for a shell.
|
|
206
|
+
#
|
|
207
|
+
# gate = Admission.new(allow: { cite: %i[data], search: [] })
|
|
208
|
+
# gate.permit?(:cite, request: Cell.user(q), arguments: Cell.data(page))
|
|
209
|
+
# # => true
|
|
210
|
+
# gate.permit?(:search, request: Cell.user(q))
|
|
211
|
+
# # => true
|
|
212
|
+
# gate.permit?(:shell, request: Cell.user(q))
|
|
213
|
+
# # => false
|
|
214
|
+
# gate.permit?(:shell, request: Cell.data(page))
|
|
215
|
+
# # => false
|
|
216
|
+
#
|
|
217
|
+
# An empty gate grants nothing. A key in `allow` is the grant; the
|
|
218
|
+
# value is which untrusted origins may supply arguments. A request
|
|
219
|
+
# cell that carries its own capability set must include the name.
|
|
220
|
+
class Admission
|
|
221
|
+
def initialize(allow: {})
|
|
222
|
+
@allow = allow.transform_keys(&:to_sym)
|
|
223
|
+
.transform_values { |kinds| Array(kinds).map(&:to_sym) }
|
|
224
|
+
.freeze
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def permit?(capability, request:, arguments: nil)
|
|
228
|
+
raise ArgumentError, 'request must be a Cell' unless request.is_a?(Cell)
|
|
229
|
+
raise ArgumentError, 'arguments must be a Cell' if !arguments.nil? && !arguments.is_a?(Cell)
|
|
230
|
+
|
|
231
|
+
cap = capability.to_sym
|
|
232
|
+
return false unless request.privileged?
|
|
233
|
+
return false unless granted?(cap, request)
|
|
234
|
+
return true if arguments.nil? || arguments.privileged?
|
|
235
|
+
|
|
236
|
+
allowed = @allow[cap] || []
|
|
237
|
+
arguments.origins.all? { |origin| origin.privileged? || allowed.include?(origin.kind) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def granted?(capability, request)
|
|
243
|
+
return false unless @allow.key?(capability)
|
|
244
|
+
return request.capabilities.include?(capability) if request.capabilities
|
|
245
|
+
|
|
246
|
+
true
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
data/lib/vangrail/parsers.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Vangrail
|
|
|
27
27
|
'S11' => 'Suicide & Self-Harm',
|
|
28
28
|
'S12' => 'Sexual Content',
|
|
29
29
|
'S13' => 'Elections',
|
|
30
|
-
'S14' => 'Code Interpreter Abuse'
|
|
30
|
+
'S14' => 'Code Interpreter Abuse',
|
|
31
31
|
}.freeze
|
|
32
32
|
|
|
33
33
|
# "safe" or "unsafe\nS1,S10".
|
|
@@ -51,8 +51,8 @@ module Vangrail
|
|
|
51
51
|
return reasoned if reasoned
|
|
52
52
|
|
|
53
53
|
lines = clean_lines(text)
|
|
54
|
-
safety = lines.
|
|
55
|
-
adversarial = lines.
|
|
54
|
+
safety = lines.detect { |l| l.match?(/\A(safe|unsafe)/i) }
|
|
55
|
+
adversarial = lines.detect { |l| l.match?(/\A(non_adversarial|adversarial)/i) }
|
|
56
56
|
return undecided(text) if safety.nil? && adversarial.nil?
|
|
57
57
|
|
|
58
58
|
unsafe = safety.to_s.match?(/\Aunsafe/i)
|
|
@@ -81,9 +81,9 @@ module Vangrail
|
|
|
81
81
|
fields = {}
|
|
82
82
|
body.each_line do |line|
|
|
83
83
|
m = line.chomp.match(
|
|
84
|
-
/\A(safety_risks_class|safety_risks_categories|adversarial_attacks_class)\s*:\s*(.*)\z
|
|
84
|
+
/\A(safety_risks_class|safety_risks_categories|adversarial_attacks_class)\s*:\s*(.*)\z/,
|
|
85
85
|
)
|
|
86
|
-
fields[m[1]] = m[2].strip.
|
|
86
|
+
fields[m[1]] = m[2].strip.delete_suffix(',') if m
|
|
87
87
|
end
|
|
88
88
|
unsafe = fields['safety_risks_class'].to_s.match?(/unsafe/i)
|
|
89
89
|
attack = fields['adversarial_attacks_class'].to_s.match?(/\Aadversarial/i)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
# A named, session-pinned posture. Copied from Grok Build's sandbox
|
|
7
|
+
# and permission model, not from a detector paper.
|
|
8
|
+
#
|
|
9
|
+
# Grok Build pins the sandbox profile for the life of a session and
|
|
10
|
+
# refuses to change it on resume. Deny rules still apply under
|
|
11
|
+
# always-approve. Child processes do not inherit KEY/SECRET/TOKEN.
|
|
12
|
+
# The same four facts live here:
|
|
13
|
+
#
|
|
14
|
+
# Profile.workspace cite and search; mutating names are denied
|
|
15
|
+
# Profile.strict cite only; only read-only tools
|
|
16
|
+
# Profile.read_only no invoke of a mutating tool
|
|
17
|
+
# Profile.off no grants
|
|
18
|
+
#
|
|
19
|
+
# The profile is chosen at Conversation construction and cannot be
|
|
20
|
+
# widened later. Deny always wins over allow and over the plan.
|
|
21
|
+
# Extra `allow:` / `deny:` on a named profile is a conflict, not a
|
|
22
|
+
# merge: `resolve` raises. Deny-wins merge applies when composing
|
|
23
|
+
# from hashes alone.
|
|
24
|
+
class Profile
|
|
25
|
+
NAMES = %i[off read_only workspace strict].freeze
|
|
26
|
+
SECRET = /key|secret|token|password|passwd|authorization/i
|
|
27
|
+
|
|
28
|
+
attr_reader :name, :allow, :deny, :readonly, :strip_secrets
|
|
29
|
+
|
|
30
|
+
def initialize(name:, allow: {}, deny: [], readonly: false, strip_secrets: true)
|
|
31
|
+
@name = name.to_sym
|
|
32
|
+
@allow = allow.transform_keys(&:to_sym)
|
|
33
|
+
.transform_values { |kinds| Array(kinds).map(&:to_sym) }
|
|
34
|
+
.freeze
|
|
35
|
+
@deny = Array(deny).map { |rule| rule.to_s.freeze }.freeze
|
|
36
|
+
@readonly = readonly
|
|
37
|
+
@strip_secrets = strip_secrets
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def readonly?
|
|
41
|
+
@readonly
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def strip_secrets?
|
|
45
|
+
@strip_secrets
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def denied?(tool)
|
|
49
|
+
needle = tool.to_s
|
|
50
|
+
deny.any? { |rule| File.fnmatch?(rule, needle, File::FNM_EXTGLOB) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.off
|
|
54
|
+
new(name: :off, allow: {}, deny: [], readonly: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.read_only
|
|
58
|
+
new(name: :read_only, allow: {}, deny: [], readonly: true)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.workspace
|
|
62
|
+
new(name: :workspace,
|
|
63
|
+
allow: { cite: %i[data], search: [] },
|
|
64
|
+
deny: %w[delete_* dump_* shell],
|
|
65
|
+
readonly: false)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.strict
|
|
69
|
+
new(name: :strict,
|
|
70
|
+
allow: { cite: %i[data] },
|
|
71
|
+
deny: %w[delete_* dump_* shell],
|
|
72
|
+
readonly: true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.resolve(value, allow: {}, deny: [])
|
|
76
|
+
extra = !allow.empty? || !Array(deny).empty?
|
|
77
|
+
return from_allow(allow, deny: deny) if value.nil?
|
|
78
|
+
raise ArgumentError, 'pass profile: or allow:/deny:, not both' if extra
|
|
79
|
+
return value if value.is_a?(self)
|
|
80
|
+
|
|
81
|
+
named(value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.named(value)
|
|
85
|
+
case value.to_sym
|
|
86
|
+
when :off then off
|
|
87
|
+
when :read_only then read_only
|
|
88
|
+
when :workspace then workspace
|
|
89
|
+
when :strict then strict
|
|
90
|
+
else raise ArgumentError, "unknown profile #{value.inspect}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.glob_denied?(name, rules)
|
|
95
|
+
needle = name.to_s
|
|
96
|
+
rules.any? { |rule| File.fnmatch?(rule, needle, File::FNM_EXTGLOB) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.from_allow(allow, deny: [])
|
|
100
|
+
deny_list = Array(deny).map(&:to_s)
|
|
101
|
+
cleaned = allow.transform_keys(&:to_sym)
|
|
102
|
+
.reject { |name, _| glob_denied?(name, deny_list) }
|
|
103
|
+
new(name: :custom, allow: cleaned, deny: deny, readonly: false, strip_secrets: true)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.strip_secrets(env)
|
|
107
|
+
env.each_with_object({}) do |(key, value), kept|
|
|
108
|
+
next if key.to_s.match?(SECRET)
|
|
109
|
+
|
|
110
|
+
kept[key] = value
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/vangrail/prompt.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Vangrail
|
|
|
23
23
|
FILTERS = {
|
|
24
24
|
'upper' => lambda(&:upcase),
|
|
25
25
|
'lower' => lambda(&:downcase),
|
|
26
|
-
'trim' => lambda(&:strip)
|
|
26
|
+
'trim' => lambda(&:strip),
|
|
27
27
|
}.freeze
|
|
28
28
|
|
|
29
29
|
TAG = /\{%\s*(\w+)\s*([^%]*?)\s*%\}/
|
|
@@ -63,9 +63,20 @@ module Vangrail
|
|
|
63
63
|
|
|
64
64
|
def lookup(vars, name)
|
|
65
65
|
name.split('.').reduce(vars) do |acc, part|
|
|
66
|
-
break nil unless acc
|
|
66
|
+
break nil unless acc
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
fetch_key(acc, part)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fetch_key(acc, part)
|
|
73
|
+
if acc.respond_to?(:key?)
|
|
74
|
+
return acc[part] if acc.key?(part)
|
|
75
|
+
return acc[part.to_sym] if acc.key?(part.to_sym)
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
elsif acc.respond_to?(:[])
|
|
79
|
+
acc[part]
|
|
69
80
|
end
|
|
70
81
|
end
|
|
71
82
|
|
data/lib/vangrail/provider.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'chat'
|
|
4
|
+
require_relative 'completion'
|
|
5
|
+
require_relative 'embeddings'
|
|
4
6
|
require_relative 'errors'
|
|
7
|
+
require_relative 'http'
|
|
8
|
+
require_relative 'providers/gateway'
|
|
5
9
|
|
|
6
10
|
module Vangrail
|
|
7
11
|
# Where the model-backed rails call, and what they may ask for there.
|
|
@@ -9,7 +13,7 @@ module Vangrail
|
|
|
9
13
|
# Every endpoint this gem talks to is OpenAI-compatible, so the differences
|
|
10
14
|
# that matter are not protocol at all. They are: how a credential resolves,
|
|
11
15
|
# whether the endpoint is up, and which model roles it can actually serve. A
|
|
12
|
-
# local proxy
|
|
16
|
+
# local proxy is named by environment and may need starting; a shared
|
|
13
17
|
# gateway resolves a token from three places and is either up or not; neither
|
|
14
18
|
# necessarily hosts a safety classifier.
|
|
15
19
|
#
|
|
@@ -20,84 +24,82 @@ module Vangrail
|
|
|
20
24
|
# provider = Vangrail::Provider.resolve # from the environment
|
|
21
25
|
# provider.chat(:judge) # => Chat, ready to ask
|
|
22
26
|
class Provider
|
|
23
|
-
ROLES = %i[guard judge].freeze
|
|
27
|
+
ROLES = %i[guard judge embed].freeze
|
|
28
|
+
ENDPOINTS = { chat: Chat, embeddings: Embeddings, completion: Completion }.freeze
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
end
|
|
30
|
+
# Presets by name, in the order `resolve` tries them.
|
|
31
|
+
def self.registry
|
|
32
|
+
@registry ||= {}
|
|
33
|
+
end
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
def self.register(provider)
|
|
36
|
+
registry[provider.name] = provider
|
|
37
|
+
provider
|
|
38
|
+
end
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
def self.[](name)
|
|
41
|
+
registry[name.to_s]
|
|
42
|
+
end
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
def self.names
|
|
45
|
+
registry.keys
|
|
46
|
+
end
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
explicit = from_env_pair(env)
|
|
66
|
-
return explicit if explicit
|
|
67
|
-
|
|
68
|
-
candidates.map { |p| p.with_env(env) }.find(&:available?)
|
|
48
|
+
# Picks a provider from the environment.
|
|
49
|
+
#
|
|
50
|
+
# GUARDRAILS_PROVIDER=<name> take this one, and fail loudly if it is
|
|
51
|
+
# unknown rather than falling back
|
|
52
|
+
# GUARDRAILS_API_BASE + key an endpoint nobody registered
|
|
53
|
+
# otherwise the first registered provider that is
|
|
54
|
+
# actually available, in registration order
|
|
55
|
+
#
|
|
56
|
+
# Returning nil is a legitimate answer: no endpoint is reachable, and the
|
|
57
|
+
# caller builds an engine with only the offline rails on it.
|
|
58
|
+
def self.resolve(env = ENV)
|
|
59
|
+
candidates = registry.each_value.to_a + [gateway_in(env)].compact
|
|
60
|
+
|
|
61
|
+
wanted = present(env['GUARDRAILS_PROVIDER'])
|
|
62
|
+
if wanted
|
|
63
|
+
found = candidates.detect { |p| p.name == wanted }
|
|
64
|
+
raise ConfigError, "unknown provider #{wanted.inspect}; known: #{names.join(', ')}" unless found
|
|
65
|
+
|
|
66
|
+
return found.with_env(env)
|
|
69
67
|
end
|
|
70
68
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
# then a function of (registry, env), which is what a caller passing an
|
|
74
|
-
# env hash is entitled to assume.
|
|
75
|
-
def gateway_in(env)
|
|
76
|
-
return nil if env.equal?(ENV)
|
|
77
|
-
|
|
78
|
-
spec = Providers::Gateway.from_environment(env)
|
|
79
|
-
spec && Providers::Gateway.provider(spec, env)
|
|
80
|
-
rescue NameError
|
|
81
|
-
nil
|
|
82
|
-
end
|
|
69
|
+
explicit = from_env_pair(env)
|
|
70
|
+
return explicit if explicit
|
|
83
71
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
base = present(env['GUARDRAILS_API_BASE'])
|
|
87
|
-
return nil unless base
|
|
88
|
-
|
|
89
|
-
new(
|
|
90
|
-
name: 'env',
|
|
91
|
-
base_url: base,
|
|
92
|
-
key_resolver: -> { present(env['GUARDRAILS_API_KEY']) },
|
|
93
|
-
models: { judge: present(env['GUARDRAILS_JUDGE_MODEL']), guard: present(env['GUARDRAILS_MODEL']) }
|
|
94
|
-
)
|
|
95
|
-
end
|
|
72
|
+
candidates.map { |p| p.with_env(env) }.detect(&:available?)
|
|
73
|
+
end
|
|
96
74
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
75
|
+
# A gateway described by the environment this call was handed, rather than
|
|
76
|
+
# by the one the registry happened to be installed from. Resolution is
|
|
77
|
+
# then a function of (registry, env), which is what a caller passing an
|
|
78
|
+
# env hash is entitled to assume.
|
|
79
|
+
def self.gateway_in(env)
|
|
80
|
+
return nil if env.equal?(ENV)
|
|
81
|
+
|
|
82
|
+
spec = Providers::Gateway.from_environment(env)
|
|
83
|
+
spec && Providers::Gateway.provider(spec, env)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# An endpoint given directly, which is how anything unregistered is used.
|
|
87
|
+
def self.from_env_pair(env)
|
|
88
|
+
base = present(env['GUARDRAILS_API_BASE'])
|
|
89
|
+
return nil unless base
|
|
90
|
+
|
|
91
|
+
new(
|
|
92
|
+
name: 'env',
|
|
93
|
+
base_url: base,
|
|
94
|
+
key_resolver: -> { present(env['GUARDRAILS_API_KEY']) },
|
|
95
|
+
models: { judge: present(env['GUARDRAILS_JUDGE_MODEL']), guard: present(env['GUARDRAILS_MODEL']),
|
|
96
|
+
embed: present(env['GUARDRAILS_EMBED_MODEL']) },
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.present(value)
|
|
101
|
+
s = value.to_s.strip
|
|
102
|
+
s.empty? ? nil : s
|
|
101
103
|
end
|
|
102
104
|
|
|
103
105
|
attr_reader :name, :base_url, :models, :guard_preset, :local
|
|
@@ -118,7 +120,8 @@ module Vangrail
|
|
|
118
120
|
def with_env(env)
|
|
119
121
|
overrides = {
|
|
120
122
|
judge: self.class.present(env['GUARDRAILS_JUDGE_MODEL']),
|
|
121
|
-
guard: self.class.present(env['GUARDRAILS_MODEL'])
|
|
123
|
+
guard: self.class.present(env['GUARDRAILS_MODEL']),
|
|
124
|
+
embed: self.class.present(env['GUARDRAILS_EMBED_MODEL']),
|
|
122
125
|
}.compact
|
|
123
126
|
base = self.class.present(env["#{env_prefix}_API_BASE"]) || base_url
|
|
124
127
|
key = self.class.present(env["#{env_prefix}_API_KEY"])
|
|
@@ -147,6 +150,16 @@ module Vangrail
|
|
|
147
150
|
!model(:guard).nil? && !guard_preset.nil?
|
|
148
151
|
end
|
|
149
152
|
|
|
153
|
+
# Can it embed. Named rather than assumed for the same reason `guard?` is:
|
|
154
|
+
# an endpoint serving chat need not serve embeddings, and a rail built on
|
|
155
|
+
# the assumption that it does is a rail that reports an error instead of a
|
|
156
|
+
# verdict. No default model is guessed either, because the name of an
|
|
157
|
+
# embedding model is deployment knowledge and a wrong guess is a 404 per
|
|
158
|
+
# check.
|
|
159
|
+
def embed?
|
|
160
|
+
!model(:embed).nil?
|
|
161
|
+
end
|
|
162
|
+
|
|
150
163
|
# Up, and holding a credential. A local endpoint is probed, because a proxy
|
|
151
164
|
# that is not running is the ordinary case rather than a failure.
|
|
152
165
|
def available?
|
|
@@ -160,11 +173,23 @@ module Vangrail
|
|
|
160
173
|
!@key_resolver.nil?
|
|
161
174
|
end
|
|
162
175
|
|
|
176
|
+
def http
|
|
177
|
+
@http ||= HTTP.new(base_url: base_url, api_key: api_key)
|
|
178
|
+
end
|
|
179
|
+
|
|
163
180
|
def chat(role = :judge, **kwargs)
|
|
164
|
-
|
|
165
|
-
|
|
181
|
+
endpoint(:chat, role, **kwargs)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def embeddings(role = :embed, **kwargs)
|
|
185
|
+
endpoint(:embeddings, role, **kwargs)
|
|
186
|
+
end
|
|
166
187
|
|
|
167
|
-
|
|
188
|
+
# Scoring rather than generation, from whichever model answers questions.
|
|
189
|
+
# No separate role: any causal model can score text, and asking a
|
|
190
|
+
# deployment to name a second one for it would be ceremony.
|
|
191
|
+
def completion(role = :judge, **kwargs)
|
|
192
|
+
endpoint(:completion, role, **kwargs)
|
|
168
193
|
end
|
|
169
194
|
|
|
170
195
|
def to_h
|
|
@@ -174,7 +199,6 @@ module Vangrail
|
|
|
174
199
|
'models' => models.transform_keys(&:to_s).compact,
|
|
175
200
|
'guard_preset' => guard_preset&.to_s,
|
|
176
201
|
'local' => local,
|
|
177
|
-
'available' => available?
|
|
178
202
|
}.compact
|
|
179
203
|
end
|
|
180
204
|
|
|
@@ -184,6 +208,13 @@ module Vangrail
|
|
|
184
208
|
|
|
185
209
|
private
|
|
186
210
|
|
|
211
|
+
def endpoint(kind, role, **kwargs)
|
|
212
|
+
name = model(role)
|
|
213
|
+
raise ConfigError, "provider #{self.name} has no #{role} model" unless name
|
|
214
|
+
|
|
215
|
+
ENDPOINTS.fetch(kind).new(model: name, http: http, **kwargs)
|
|
216
|
+
end
|
|
217
|
+
|
|
187
218
|
def env_prefix
|
|
188
219
|
name.upcase.gsub(/[^A-Z0-9]/, '_')
|
|
189
220
|
end
|