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,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vangrail
|
|
4
|
+
# The Beta distribution, enough of it to put an honest interval on a rate.
|
|
5
|
+
#
|
|
6
|
+
# A rail that fired on 0 of 48 benign texts has a false-alarm rate somewhere
|
|
7
|
+
# below about one in twenty, and nothing in the corpus says where. Reporting
|
|
8
|
+
# the point estimate treats "I measured nothing" as "the rate is the smoothing
|
|
9
|
+
# constant", which is exactly the direction that flatters a detector.
|
|
10
|
+
#
|
|
11
|
+
# The Bayesian answer is the one from the estimation literature that language
|
|
12
|
+
# modelling has used since Good: the rate is not a number, it is a posterior,
|
|
13
|
+
# and with a Beta prior over a binomial count that posterior is a Beta. Taking
|
|
14
|
+
# its pessimistic tail rather than its mean gives a bound that shrinks as the
|
|
15
|
+
# corpus grows and stays conservative while it is small.
|
|
16
|
+
#
|
|
17
|
+
# Implemented here rather than pulled in, because the runtime has no
|
|
18
|
+
# dependencies: the regularised incomplete beta function by the standard
|
|
19
|
+
# continued fraction, and its inverse by bisection, which is slow and exact
|
|
20
|
+
# enough for a table computed once.
|
|
21
|
+
module Beta
|
|
22
|
+
ITERATIONS = 200
|
|
23
|
+
EPSILON = 1e-12
|
|
24
|
+
TINY = 1e-300
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# P(X <= x) for X ~ Beta(a, b): the regularised incomplete beta function.
|
|
29
|
+
def cdf(x, a, b)
|
|
30
|
+
return 0.0 if x <= 0
|
|
31
|
+
return 1.0 if x >= 1
|
|
32
|
+
|
|
33
|
+
front = Math.exp(Math.lgamma(a + b).first - Math.lgamma(a).first - Math.lgamma(b).first +
|
|
34
|
+
(a * Math.log(x)) + (b * Math.log(1 - x)))
|
|
35
|
+
# The continued fraction converges quickly on one side of the mode and
|
|
36
|
+
# slowly on the other, so the far side is computed from the symmetry.
|
|
37
|
+
if x < (a + 1) / (a + b + 2)
|
|
38
|
+
front * continued_fraction(x, a, b) / a
|
|
39
|
+
else
|
|
40
|
+
1 - (Math.exp(Math.lgamma(a + b).first - Math.lgamma(a).first - Math.lgamma(b).first +
|
|
41
|
+
(b * Math.log(1 - x)) + (a * Math.log(x))) *
|
|
42
|
+
continued_fraction(1 - x, b, a) / b)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The value below which a Beta(a, b) sits with probability `p`.
|
|
47
|
+
#
|
|
48
|
+
# Bisection rather than Newton: this runs once per rail per confidence
|
|
49
|
+
# level, the function is monotone, and fifty halvings put it well inside any
|
|
50
|
+
# precision a likelihood ratio needs.
|
|
51
|
+
def quantile(p, a, b)
|
|
52
|
+
return 0.0 if p <= 0
|
|
53
|
+
return 1.0 if p >= 1
|
|
54
|
+
|
|
55
|
+
low = 0.0
|
|
56
|
+
high = 1.0
|
|
57
|
+
60.times do
|
|
58
|
+
mid = (low + high) / 2
|
|
59
|
+
if cdf(mid, a, b) < p
|
|
60
|
+
low = mid
|
|
61
|
+
else
|
|
62
|
+
high = mid
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
(low + high) / 2
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Lentz's algorithm for the continued fraction of the incomplete beta.
|
|
69
|
+
def continued_fraction(x, a, b)
|
|
70
|
+
qab = a + b
|
|
71
|
+
qap = a + 1
|
|
72
|
+
qam = a - 1
|
|
73
|
+
c = 1.0
|
|
74
|
+
d = 1 - (qab * x / qap)
|
|
75
|
+
d = TINY if d.abs < TINY
|
|
76
|
+
d = 1 / d
|
|
77
|
+
h = d
|
|
78
|
+
|
|
79
|
+
(1..ITERATIONS).each do |m|
|
|
80
|
+
m2 = 2 * m
|
|
81
|
+
numerator = m * (b - m) * x / ((qam + m2) * (a + m2))
|
|
82
|
+
d = 1 + (numerator * d)
|
|
83
|
+
d = TINY if d.abs < TINY
|
|
84
|
+
c = 1 + (numerator / c)
|
|
85
|
+
c = TINY if c.abs < TINY
|
|
86
|
+
d = 1 / d
|
|
87
|
+
h *= d * c
|
|
88
|
+
|
|
89
|
+
numerator = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
|
|
90
|
+
d = 1 + (numerator * d)
|
|
91
|
+
d = TINY if d.abs < TINY
|
|
92
|
+
c = 1 + (numerator / c)
|
|
93
|
+
c = TINY if c.abs < TINY
|
|
94
|
+
d = 1 / d
|
|
95
|
+
step = d * c
|
|
96
|
+
h *= step
|
|
97
|
+
break if (step - 1).abs < EPSILON
|
|
98
|
+
end
|
|
99
|
+
h
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vangrail
|
|
4
|
+
# Reads the environment into an engine. A class rather than a method so each
|
|
5
|
+
# decision is separable and testable on its own.
|
|
6
|
+
class Builder
|
|
7
|
+
DEFAULT_RAILS = %i[input context output].freeze
|
|
8
|
+
ALL_RAILS = %i[input context output grounding secrets patterns links multiturn privacy
|
|
9
|
+
markup budget semantic perplexity bayes linear].freeze
|
|
10
|
+
|
|
11
|
+
# Deterministic input patterns, kept small on purpose. Each is a phrase
|
|
12
|
+
# whose presence is itself the violation; anything needing judgement belongs
|
|
13
|
+
# in a policy rail, where a false positive is a model's opinion rather than
|
|
14
|
+
# a hard rule.
|
|
15
|
+
INJECTION_PATTERNS = {
|
|
16
|
+
'instruction_override' => /\bignore\s+(?:all\s+|any\s+)?(?:previous|prior|above|earlier)\s+instructions?\b/i,
|
|
17
|
+
'prompt_disclosure' => /\b(?:reveal|print|repeat|show|output)\s+(?:me\s+)?(?:your|the|its)?\s*
|
|
18
|
+
(?:system\s+prompt|initial\s+instructions|developer\s+message)\b/xi,
|
|
19
|
+
'role_reset' => /\byou\s+are\s+now\s+(?:a|an|in)\b.{0,40}\b(?:mode|persona|dan|jailbreak)\b/i,
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :env
|
|
23
|
+
|
|
24
|
+
def initialize(env = ENV)
|
|
25
|
+
@env = env
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def engine
|
|
29
|
+
return Engine.new(on_error: on_error, cache: cache?) if off?
|
|
30
|
+
return config_engine if config_dir
|
|
31
|
+
|
|
32
|
+
Engine.new(input: input_rails, context: context_rails, output: output_rails,
|
|
33
|
+
on_error: on_error, cache: cache?)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def session(prior:, **kwargs)
|
|
37
|
+
Session.new(engine: engine, prior: prior, **kwargs)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The offline stack that does not need a folder or an endpoint: patterns,
|
|
41
|
+
# concepts, near-copies, the decoding pass, and the language posture.
|
|
42
|
+
# `Config#engine(stdlib: true)` prepends the same list so a NeMo folder
|
|
43
|
+
# does not certain-pass a question nothing here can read.
|
|
44
|
+
def self.deterministic(side)
|
|
45
|
+
side = side.to_sym
|
|
46
|
+
return [] if side == :output
|
|
47
|
+
|
|
48
|
+
core = [
|
|
49
|
+
(Rails::InjectedInstructions.new if side == :context),
|
|
50
|
+
(if side == :input
|
|
51
|
+
Rails::Pattern.new(patterns: INJECTION_PATTERNS, name: 'injection_patterns',
|
|
52
|
+
sides: [side])
|
|
53
|
+
end),
|
|
54
|
+
Rails::Jailbreak.new(sides: [side]),
|
|
55
|
+
Rails::Paraphrase.new(sides: [side]),
|
|
56
|
+
Rails::Alignment.new(sides: [side]),
|
|
57
|
+
Rails::Similarity.new(sides: [side]),
|
|
58
|
+
Rails::ManyShot.new(sides: [side]),
|
|
59
|
+
].compact
|
|
60
|
+
extras = [Rails::Obfuscation.new(rails: core, sides: [side])]
|
|
61
|
+
extras << Rails::Hidden.new(rails: core) if side == :context
|
|
62
|
+
extras << Rails::Language.new(sides: [side])
|
|
63
|
+
core + extras
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def enabled
|
|
67
|
+
text = env['GUARDRAILS_RAILS'].to_s.strip
|
|
68
|
+
return DEFAULT_RAILS if text.empty?
|
|
69
|
+
return [] if off_value?(text) || text.casecmp('none').zero?
|
|
70
|
+
return ALL_RAILS.dup if text.casecmp('all').zero?
|
|
71
|
+
|
|
72
|
+
names = text.split(/[,\s]+/).map { |s| s.strip.downcase.to_sym }
|
|
73
|
+
unknown = names - ALL_RAILS
|
|
74
|
+
raise ArgumentError, "unknown rail name(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
75
|
+
|
|
76
|
+
names & ALL_RAILS
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def on?(rail)
|
|
80
|
+
enabled.include?(rail)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def off?
|
|
84
|
+
off_value?(env['GUARDRAILS'])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def on_error
|
|
88
|
+
env['GUARDRAILS_ON_ERROR'].to_s.strip.casecmp('block').zero? ? :block : :allow
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cache?
|
|
92
|
+
!off_value?(env['GUARDRAILS_CACHE'])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def config_dir
|
|
96
|
+
present(env['GUARDRAILS_CONFIG'])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def server_url
|
|
100
|
+
present(env['GUARDRAILS_SERVER'])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The endpoint the model-backed rails call, or nil when none is reachable.
|
|
104
|
+
def provider
|
|
105
|
+
return @provider if defined?(@provider)
|
|
106
|
+
|
|
107
|
+
@provider = Provider.resolve(env)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def config_engine
|
|
113
|
+
Config.load(config_dir).engine(provider: provider, on_error: on_error, cache: cache?)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The pattern rail runs whenever any input rail does. It costs microseconds
|
|
117
|
+
# and it is the only part of the input side that still works when the
|
|
118
|
+
# endpoint is down.
|
|
119
|
+
def input_rails
|
|
120
|
+
return [] unless on?(:input) || on?(:patterns)
|
|
121
|
+
|
|
122
|
+
rails = self.class.deterministic(:input)
|
|
123
|
+
# A question carrying the canary is too late to prevent and worth
|
|
124
|
+
# knowing: the prompt is already out.
|
|
125
|
+
rails << canary(:input) if canary_token
|
|
126
|
+
# Opt-in: it rewrites the question before the model sees it, which is a
|
|
127
|
+
# deployment's call rather than a default. Where the endpoint is a third
|
|
128
|
+
# party it is close to obligatory, and where it is a local proxy it buys
|
|
129
|
+
# little.
|
|
130
|
+
rails << Rails::Bayes.new(sides: [:input]) if on?(:bayes)
|
|
131
|
+
rails << Rails::Linear.new(sides: [:input]) if on?(:linear)
|
|
132
|
+
rails << semantic(:input) if on?(:semantic)
|
|
133
|
+
rails << perplexity(:input) if on?(:perplexity)
|
|
134
|
+
rails << Rails::PersonalData.new if on?(:privacy)
|
|
135
|
+
rails << Rails::Budget.new(sides: [:input]) if on?(:budget)
|
|
136
|
+
# Off unless asked for: they read history, and a caller that threads none
|
|
137
|
+
# would have every input check come back uncertain, which is true and
|
|
138
|
+
# useless. Conversation is what makes them worth having.
|
|
139
|
+
#
|
|
140
|
+
# The deterministic one goes first so a refused question asked again
|
|
141
|
+
# never reaches the judge: it is free, and the round trip is not.
|
|
142
|
+
rails.concat(multiturn_rails) if on?(:multiturn)
|
|
143
|
+
rails << judged(:input) if on?(:input)
|
|
144
|
+
rails.compact
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Deterministic, offline, and free, so it is on by default. The document a
|
|
148
|
+
# retrieval step just fetched is the side an attacker can usually reach
|
|
149
|
+
# without touching the application, and nothing else in this engine reads it.
|
|
150
|
+
#
|
|
151
|
+
# The decoding pass matters more here than anywhere else: a page an
|
|
152
|
+
# attacker edits is a page they can base64.
|
|
153
|
+
def context_rails
|
|
154
|
+
return [] unless on?(:context)
|
|
155
|
+
|
|
156
|
+
# Two passes over the same definitions, for the two ways a page hides
|
|
157
|
+
# one: encoded so the patterns cannot read it, or in markup a reader
|
|
158
|
+
# never sees. Language sits outside the decoding pass and last among
|
|
159
|
+
# the free rails: every rail above is a rule about English or Dutch
|
|
160
|
+
# words, and a page in neither has been passed by all of them without
|
|
161
|
+
# being read. Reporting that costs a token count and keeps `certain?`
|
|
162
|
+
# honest.
|
|
163
|
+
rails = self.class.deterministic(:context)
|
|
164
|
+
rails << Rails::Bayes.new(sides: [:context]) if on?(:bayes)
|
|
165
|
+
rails << Rails::Linear.new(sides: [:context]) if on?(:linear)
|
|
166
|
+
rails << semantic(:context) if on?(:semantic)
|
|
167
|
+
rails << perplexity(:context) if on?(:perplexity)
|
|
168
|
+
rails << Rails::Budget.new(sides: [:context]) if on?(:budget)
|
|
169
|
+
rails
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def output_rails
|
|
173
|
+
rails = []
|
|
174
|
+
rails << Rails::Secrets.new if on?(:secrets) || on?(:output)
|
|
175
|
+
rails << canary(:output) if canary_token
|
|
176
|
+
# Naming the file is the opt-in, the same way the canary token is. Only
|
|
177
|
+
# the application knows what its prompt says, and a rail that guessed
|
|
178
|
+
# would be guarding a text nobody wrote.
|
|
179
|
+
rails << prompt_leak if protected_prompt
|
|
180
|
+
# Off by default: a desk whose client renders answers as plain text does
|
|
181
|
+
# not need it, and stripping markup nobody would have executed is noise
|
|
182
|
+
# in the result.
|
|
183
|
+
rails << Rails::Markup.new if on?(:markup)
|
|
184
|
+
rails << exfiltration if link_hosts || on?(:links)
|
|
185
|
+
rails << judged(:output) if on?(:output)
|
|
186
|
+
rails << grounding if on?(:grounding)
|
|
187
|
+
rails.compact
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The hosts an answer may link to. Not defaulted to anything, because the
|
|
191
|
+
# empty allowlist means "no links at all", which is right for an
|
|
192
|
+
# application that said so and wrong to impose on one that never mentioned
|
|
193
|
+
# links. Naming the variable is the opt-in.
|
|
194
|
+
def link_hosts
|
|
195
|
+
present(env['GUARDRAILS_LINK_HOSTS'])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The pair, because they cover different halves. Escalation sees a retry
|
|
199
|
+
# after a refusal and nothing before one; the judge reads a sequence that
|
|
200
|
+
# has never been refused, which is what the published multi-turn methods
|
|
201
|
+
# are built to produce.
|
|
202
|
+
def multiturn_rails
|
|
203
|
+
rails = [Rails::Escalation.new]
|
|
204
|
+
rails << if provider&.available? && provider.model(:judge)
|
|
205
|
+
Rails::Trajectory.new(provider: provider, every: judge_every)
|
|
206
|
+
else
|
|
207
|
+
missing('trajectory', :input)
|
|
208
|
+
end
|
|
209
|
+
rails
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# One round trip per turn is the honest cost, and a desk may not want to
|
|
213
|
+
# pay it every turn. A staged escalation takes several turns by
|
|
214
|
+
# construction and cannot finish inside a gap of two.
|
|
215
|
+
def judge_every
|
|
216
|
+
value = env['GUARDRAILS_TRAJECTORY_EVERY'].to_i
|
|
217
|
+
value.positive? ? value : 1
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The rail class follows what the endpoint can actually serve. A provider
|
|
221
|
+
# hosting a classifier gets one; a provider serving only instruct models
|
|
222
|
+
# gets a written policy in front of one, which is the same job done
|
|
223
|
+
# differently rather than the same job skipped.
|
|
224
|
+
def judged(side)
|
|
225
|
+
return remote(side) if server_url
|
|
226
|
+
return missing("#{side}_model", side) unless provider&.available?
|
|
227
|
+
return guard_model(side) if provider.guard?
|
|
228
|
+
return missing("#{side}_model", side) unless provider.model(:judge)
|
|
229
|
+
|
|
230
|
+
Rails::SelfCheck.new(provider: provider, sides: [side], name: "policy_#{side}")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Asked for and unbuildable. Kept in the list rather than dropped, so the
|
|
234
|
+
# engine's pass stays uncertain instead of resting on the offline rails.
|
|
235
|
+
#
|
|
236
|
+
# `name` is what the rail would have been; `side` is where it would have
|
|
237
|
+
# run. They are not the same thing, and conflating them is how a grounding
|
|
238
|
+
# placeholder ends up claiming a side that does not exist.
|
|
239
|
+
def missing(name, side)
|
|
240
|
+
reason =
|
|
241
|
+
if provider.nil?
|
|
242
|
+
'no endpoint resolved: set GUARDRAILS_API_BASE, or start a local one'
|
|
243
|
+
elsif provider.available? && provider.model(:judge).nil?
|
|
244
|
+
'no judge model; set LLMLITE_MODEL or GUARDRAILS_JUDGE_MODEL'
|
|
245
|
+
else
|
|
246
|
+
"#{provider.name} is not available at #{provider.base_url}"
|
|
247
|
+
end
|
|
248
|
+
Rails::Missing.new(reason: reason, name: name.to_s, sides: [side])
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def remote(side)
|
|
252
|
+
Rails::Remote.new(base_url: server_url, config_id: present(env['GUARDRAILS_CONFIG_ID']),
|
|
253
|
+
api_key: present(env['GUARDRAILS_SERVER_API_KEY']), sides: [side])
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def guard_model(side)
|
|
257
|
+
Rails::GuardModel.new(provider: provider, reasoning: truthy?(env['GUARDRAILS_REASONING']),
|
|
258
|
+
sides: [side])
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# The application generates the token, puts it in its prompt, and names it
|
|
262
|
+
# here. Nothing can be checked without one, so its absence is the off
|
|
263
|
+
# switch.
|
|
264
|
+
def canary_token
|
|
265
|
+
present(env['GUARDRAILS_CANARY'])
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def canary(side)
|
|
269
|
+
Rails::Canary.new(tokens: canary_token.split(/[,\s]+/), sides: [side])
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The text that must not come back out, read from a file because a system
|
|
273
|
+
# prompt in an environment variable is a system prompt nobody can read.
|
|
274
|
+
# An unreadable path raises rather than silently building no rail: a
|
|
275
|
+
# guardrail that was asked for and quietly absent is the failure this gem
|
|
276
|
+
# exists to prevent.
|
|
277
|
+
def protected_prompt
|
|
278
|
+
return @protected_prompt if defined?(@protected_prompt)
|
|
279
|
+
|
|
280
|
+
path = present(env['GUARDRAILS_PROMPT_FILE'])
|
|
281
|
+
@protected_prompt =
|
|
282
|
+
if path.nil?
|
|
283
|
+
nil
|
|
284
|
+
else
|
|
285
|
+
begin
|
|
286
|
+
File.read(path)
|
|
287
|
+
rescue SystemCallError => e
|
|
288
|
+
raise ConfigError, "GUARDRAILS_PROMPT_FILE #{path.inspect} could not be read: #{e.message}"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Asked for by name, because it costs a round trip per check and because
|
|
294
|
+
# every document embedded on a third-party endpoint is a document sent
|
|
295
|
+
# there. A provider serving no embedding model leaves the placeholder, so
|
|
296
|
+
# the pass stays uncertain rather than resting on the offline rails.
|
|
297
|
+
def semantic(side)
|
|
298
|
+
return missing('semantic', side) unless provider&.available? && provider.embed?
|
|
299
|
+
|
|
300
|
+
Rails::Semantic.new(embeddings: provider.embeddings, sides: [side],
|
|
301
|
+
threshold: semantic_threshold)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Off unless asked for, and not only for the round trip: the threshold is a
|
|
305
|
+
# property of the endpoint's model, and an uncalibrated detector switched on
|
|
306
|
+
# by default is a detector that blocks somebody's shell transcript.
|
|
307
|
+
def perplexity(side)
|
|
308
|
+
return missing('perplexity', side) unless provider&.available? && provider.model(:judge)
|
|
309
|
+
|
|
310
|
+
Rails::Perplexity.new(completion: provider.completion, sides: [side],
|
|
311
|
+
threshold: perplexity_threshold)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def perplexity_threshold
|
|
315
|
+
value = env['GUARDRAILS_PERPLEXITY_THRESHOLD'].to_f
|
|
316
|
+
value.positive? ? value : Rails::Perplexity::THRESHOLD
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def semantic_threshold
|
|
320
|
+
value = env['GUARDRAILS_SEMANTIC_THRESHOLD'].to_f
|
|
321
|
+
value.positive? ? value : Rails::Semantic::THRESHOLD
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def prompt_leak
|
|
325
|
+
Rails::PromptLeak.new(protected_text: protected_prompt)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def exfiltration
|
|
329
|
+
hosts = link_hosts.to_s.split(/[,\s]+/).reject(&:empty?)
|
|
330
|
+
images = present(env['GUARDRAILS_IMAGE_HOSTS'])
|
|
331
|
+
Rails::Exfiltration.new(allow_hosts: hosts,
|
|
332
|
+
allow_images: images&.split(/[,\s]+/)&.reject(&:empty?))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def grounding
|
|
336
|
+
return missing('grounding', :output) unless provider&.available? && provider.model(:judge)
|
|
337
|
+
|
|
338
|
+
Rails::Grounding.new(provider: provider)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def off_value?(value)
|
|
342
|
+
%w[0 off false no].include?(value.to_s.strip.downcase)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def truthy?(value)
|
|
346
|
+
%w[1 on true yes].include?(value.to_s.strip.downcase)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def present(value)
|
|
350
|
+
s = value.to_s.strip
|
|
351
|
+
s.empty? ? nil : s
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
data/lib/vangrail/chat.rb
CHANGED
|
@@ -19,24 +19,16 @@ module Vangrail
|
|
|
19
19
|
|
|
20
20
|
attr_reader :model, :http, :max_tokens, :temperature, :extra
|
|
21
21
|
|
|
22
|
-
def initialize(model:,
|
|
22
|
+
def initialize(model:, http: nil, base_url: nil, api_key: nil, max_tokens: 128,
|
|
23
23
|
temperature: 0, extra: {}, open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT,
|
|
24
|
-
read_timeout:
|
|
25
|
-
if http.nil? && base_url.to_s.strip.empty?
|
|
26
|
-
raise ArgumentError,
|
|
27
|
-
'a Chat needs a base_url or an http client'
|
|
28
|
-
end
|
|
29
|
-
|
|
24
|
+
read_timeout: HTTP::DEFAULT_READ_TIMEOUT)
|
|
30
25
|
@model = model
|
|
31
26
|
@max_tokens = max_tokens
|
|
32
27
|
@temperature = temperature
|
|
33
28
|
@extra = extra
|
|
34
|
-
@http = http
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
open_timeout: open_timeout,
|
|
38
|
-
read_timeout: read_timeout
|
|
39
|
-
)
|
|
29
|
+
@http = HTTP.build(http: http, base_url: base_url, api_key: api_key,
|
|
30
|
+
open_timeout: open_timeout, read_timeout: read_timeout,
|
|
31
|
+
missing: 'a Chat needs a base_url or an http client')
|
|
40
32
|
end
|
|
41
33
|
|
|
42
34
|
# A copy pointed at a different model on the same endpoint and credentials.
|
|
@@ -47,13 +39,23 @@ module Vangrail
|
|
|
47
39
|
)
|
|
48
40
|
end
|
|
49
41
|
|
|
50
|
-
|
|
42
|
+
# `conversation:` is the application path: the messages are whatever
|
|
43
|
+
# Conversation#messages will assemble, so a retrieved page cannot be
|
|
44
|
+
# spliced into the instruction by handing this method a raw array.
|
|
45
|
+
def ask(messages = nil, conversation: nil, system: nil, max_tokens: nil)
|
|
46
|
+
if conversation
|
|
47
|
+
raise ArgumentError, 'pass conversation: or messages, not both' if messages
|
|
48
|
+
|
|
49
|
+
messages = conversation.messages(system: system.to_s)
|
|
50
|
+
end
|
|
51
|
+
raise ArgumentError, 'ask needs messages or conversation:' if messages.nil?
|
|
52
|
+
|
|
51
53
|
payload = {
|
|
52
54
|
'model' => model,
|
|
53
55
|
'messages' => normalize(messages),
|
|
54
56
|
'temperature' => temperature,
|
|
55
57
|
'max_tokens' => max_tokens || @max_tokens,
|
|
56
|
-
'stream' => false
|
|
58
|
+
'stream' => false,
|
|
57
59
|
}.merge(extra)
|
|
58
60
|
|
|
59
61
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Vangrail
|
|
4
4
|
class Client
|
|
5
|
-
# A guardrailed chat
|
|
5
|
+
# A guardrailed chat turn, read out of either server response shape.
|
|
6
6
|
#
|
|
7
7
|
# The OpenAI-compatible shape puts the answer in choices[0].message.content
|
|
8
8
|
# and rail bookkeeping under a top-level `guardrails` object. The older shape
|
|
9
9
|
# answers with a bare {role, content} message (or a list of them) and puts
|
|
10
10
|
# bookkeeping at the top level. Both appear in the wild depending on the
|
|
11
11
|
# server version, so this reads whichever is present.
|
|
12
|
-
class
|
|
12
|
+
class Turn
|
|
13
13
|
# Server-side names for the variables holding the rail that stopped a turn.
|
|
14
14
|
INPUT_RAIL_VAR = 'triggered_input_rail'
|
|
15
15
|
OUTPUT_RAIL_VAR = 'triggered_output_rail'
|
|
@@ -30,7 +30,7 @@ module Vangrail
|
|
|
30
30
|
|
|
31
31
|
messages = raw['messages']
|
|
32
32
|
if messages.is_a?(Array)
|
|
33
|
-
last = messages.reverse.
|
|
33
|
+
last = messages.reverse.detect { |m| m.is_a?(Hash) && m['role'].to_s == 'assistant' }
|
|
34
34
|
return last['content'].to_s if last
|
|
35
35
|
end
|
|
36
36
|
''
|
data/lib/vangrail/client.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative 'client/
|
|
3
|
+
require_relative 'client/turn'
|
|
4
4
|
require_relative 'errors'
|
|
5
5
|
require_relative 'http'
|
|
6
6
|
require_relative 'result'
|
|
@@ -23,7 +23,11 @@ module Vangrail
|
|
|
23
23
|
COMPLETIONS_PATH = '/v1/chat/completions'
|
|
24
24
|
PROTOCOLS = %i[auto nested flat].freeze
|
|
25
25
|
|
|
26
|
-
RAIL_VARS = [
|
|
26
|
+
RAIL_VARS = [Turn::INPUT_RAIL_VAR, Turn::OUTPUT_RAIL_VAR].freeze
|
|
27
|
+
|
|
28
|
+
# One guarded completion's arguments, so the wire methods take one value.
|
|
29
|
+
Request = Struct.new(:messages, :config_id, :config_ids, :options, :context,
|
|
30
|
+
:thread_id, :model, :extra, keyword_init: true)
|
|
27
31
|
|
|
28
32
|
attr_reader :config_id, :model, :protocol, :http
|
|
29
33
|
|
|
@@ -31,9 +35,12 @@ module Vangrail
|
|
|
31
35
|
# of those happens, so a caller can report "not yet known" honestly.
|
|
32
36
|
attr_reader :checks_supported
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
# The 0.1.0 name. Prefer Turn in new code.
|
|
39
|
+
Completion = Turn
|
|
40
|
+
|
|
41
|
+
def initialize(http: nil, base_url: nil, config_id: nil, model: nil, api_key: nil,
|
|
42
|
+
protocol: :auto, open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT,
|
|
43
|
+
read_timeout: HTTP::DEFAULT_READ_TIMEOUT)
|
|
37
44
|
unless PROTOCOLS.include?(protocol)
|
|
38
45
|
raise ArgumentError,
|
|
39
46
|
"protocol must be one of #{PROTOCOLS.join(', ')}"
|
|
@@ -43,10 +50,9 @@ module Vangrail
|
|
|
43
50
|
@model = model
|
|
44
51
|
@protocol = protocol
|
|
45
52
|
@checks_supported = nil
|
|
46
|
-
@http = http
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
53
|
+
@http = HTTP.build(http: http, base_url: base_url, api_key: api_key,
|
|
54
|
+
open_timeout: open_timeout, read_timeout: read_timeout,
|
|
55
|
+
missing: 'a Client needs a base_url or an http client')
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
def base_url
|
|
@@ -93,10 +99,13 @@ module Vangrail
|
|
|
93
99
|
# answer as well as checking it.
|
|
94
100
|
def chat(messages:, config_id: nil, config_ids: nil, options: nil, context: nil,
|
|
95
101
|
thread_id: nil, model: nil, **extra)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
request = Request.new(messages: messages, config_id: config_id, config_ids: config_ids,
|
|
103
|
+
options: options, context: context, thread_id: thread_id,
|
|
104
|
+
model: model, extra: extra)
|
|
105
|
+
opts = merge_options(request.options)
|
|
106
|
+
body = request.extra.merge(messages: normalize(request.messages))
|
|
107
|
+
chosen = { config_id: request.config_id || @config_id, config_ids: request.config_ids }
|
|
108
|
+
Turn.new(send_payload(body, chosen, opts, request.context, request.thread_id, request.model))
|
|
100
109
|
end
|
|
101
110
|
|
|
102
111
|
private
|
|
@@ -119,12 +128,12 @@ module Vangrail
|
|
|
119
128
|
content: body['content'], raw: body)
|
|
120
129
|
end
|
|
121
130
|
|
|
122
|
-
def from_completion(
|
|
123
|
-
return Result.passed(rail: rail.to_s, raw:
|
|
131
|
+
def from_completion(turn, rail)
|
|
132
|
+
return Result.passed(rail: rail.to_s, raw: turn.raw) if turn.allowed?
|
|
124
133
|
|
|
125
|
-
reason =
|
|
126
|
-
Result.blocked(rail: reason || rail.to_s, content:
|
|
127
|
-
raw:
|
|
134
|
+
reason = turn.triggered_rail || turn.stopped_rails.first&.dig('name')
|
|
135
|
+
Result.blocked(rail: reason || rail.to_s, content: turn.content, reason: reason,
|
|
136
|
+
raw: turn.raw)
|
|
128
137
|
end
|
|
129
138
|
|
|
130
139
|
def check_options(rail)
|
data/lib/vangrail/colang/ast.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
|
|
3
5
|
module Vangrail
|
|
4
6
|
module Colang
|
|
5
7
|
# The whole grammar this gem executes, as data. Everything the parser can
|
|
@@ -23,14 +25,38 @@ module Vangrail
|
|
|
23
25
|
Program.new(
|
|
24
26
|
flows: flows.merge(other.flows),
|
|
25
27
|
bot_messages: bot_messages.merge(other.bot_messages),
|
|
26
|
-
user_messages: user_messages.merge(other.user_messages)
|
|
28
|
+
user_messages: user_messages.merge(other.user_messages),
|
|
27
29
|
)
|
|
28
30
|
end
|
|
31
|
+
|
|
32
|
+
# Every `bot <name>` in every flow has a `define bot`. The walk covers
|
|
33
|
+
# unreached `if` branches and flows no config names: a half-loaded
|
|
34
|
+
# guardrail must not load, so a dead-branch bot in a merged `.co` file
|
|
35
|
+
# fails the folder. Called after parse/merge, not during parse, so a
|
|
36
|
+
# bot defined in another file is visible once the programs are joined.
|
|
37
|
+
def check!
|
|
38
|
+
flows.each { |name, flow| check_body(flow.body, name) }
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def check_body(body, flow_name)
|
|
43
|
+
Array(body).each do |node|
|
|
44
|
+
case node
|
|
45
|
+
when Bot
|
|
46
|
+
next if bot_message(node.message)
|
|
47
|
+
|
|
48
|
+
raise ColangError, "no `define bot #{node.message}` for flow #{flow_name.inspect}"
|
|
49
|
+
when If
|
|
50
|
+
check_body(node.then_body, flow_name)
|
|
51
|
+
check_body(node.else_body, flow_name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
29
55
|
end
|
|
30
56
|
|
|
31
57
|
Flow = Struct.new(:name, :body, :subflow, keyword_init: true)
|
|
32
58
|
|
|
33
|
-
# $var =
|
|
59
|
+
# $var = <value>
|
|
34
60
|
Assign = Struct.new(:variable, :expression, keyword_init: true)
|
|
35
61
|
|
|
36
62
|
# execute action(key="value")
|
|
@@ -44,7 +70,7 @@ module Vangrail
|
|
|
44
70
|
|
|
45
71
|
If = Struct.new(:condition, :then_body, :else_body, keyword_init: true)
|
|
46
72
|
|
|
47
|
-
#
|
|
73
|
+
# Values
|
|
48
74
|
Var = Struct.new(:name, keyword_init: true)
|
|
49
75
|
Not = Struct.new(:expression, keyword_init: true)
|
|
50
76
|
Compare = Struct.new(:left, :operator, :right, keyword_init: true)
|