mockserver-client 7.1.0 → 7.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 +113 -1
- data/lib/mockserver/binary_launcher.rb +31 -1
- data/lib/mockserver/client.rb +311 -7
- data/lib/mockserver/forward_chain_expectation.rb +16 -6
- data/lib/mockserver/llm.rb +855 -0
- data/lib/mockserver/mcp.rb +453 -0
- data/lib/mockserver/models.rb +427 -11
- data/lib/mockserver/rspec.rb +56 -0
- data/lib/mockserver/version.rb +1 -1
- data/lib/mockserver-client.rb +2 -0
- metadata +5 -2
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module MockServer
|
|
6
|
+
# Idiomatic Ruby LLM-mocking builder API for MockServer.
|
|
7
|
+
#
|
|
8
|
+
# This module mirrors the Java/Node/Python client LLM builders
|
|
9
|
+
# (+LlmMockBuilder+, +LlmConversationBuilder+, +TurnBuilder+,
|
|
10
|
+
# +LlmFailoverBuilder+) and the underlying server-side model classes
|
|
11
|
+
# (+Completion+, +ToolUse+, +Usage+, +StreamingPhysics+, +EmbeddingResponse+).
|
|
12
|
+
#
|
|
13
|
+
# The builders produce plain Ruby Hashes with camelCase string keys that
|
|
14
|
+
# serialise to exactly the same expectation wire JSON the other clients emit.
|
|
15
|
+
# The expectation action is carried in the +httpLlmResponse+ field (a sibling
|
|
16
|
+
# of +httpRequest+, +scenarioName+, +scenarioState+, +newScenarioState+,
|
|
17
|
+
# +times+, +timeToLive+, +httpResponse+). Nil fields are omitted (NON_NULL).
|
|
18
|
+
#
|
|
19
|
+
# @example A single completion mock
|
|
20
|
+
# MockServer::LLM.llm_mock('/v1/chat/completions')
|
|
21
|
+
# .with_provider(MockServer::LLM::Provider::OPENAI)
|
|
22
|
+
# .with_model('gpt-4o')
|
|
23
|
+
# .responding_with(MockServer::LLM.completion.with_text('Hello!'))
|
|
24
|
+
# .apply_to(client)
|
|
25
|
+
module LLM
|
|
26
|
+
# LLM provider names. Serialized on the wire as the upper-case enum name.
|
|
27
|
+
module Provider
|
|
28
|
+
ANTHROPIC = 'ANTHROPIC'
|
|
29
|
+
OPENAI = 'OPENAI'
|
|
30
|
+
OPENAI_RESPONSES = 'OPENAI_RESPONSES'
|
|
31
|
+
GEMINI = 'GEMINI'
|
|
32
|
+
BEDROCK = 'BEDROCK'
|
|
33
|
+
AZURE_OPENAI = 'AZURE_OPENAI'
|
|
34
|
+
OLLAMA = 'OLLAMA'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Parsed-message roles (mirrors org.mockserver.llm.ParsedMessage.Role).
|
|
38
|
+
module Role
|
|
39
|
+
USER = 'USER'
|
|
40
|
+
ASSISTANT = 'ASSISTANT'
|
|
41
|
+
TOOL = 'TOOL'
|
|
42
|
+
SYSTEM = 'SYSTEM'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @api private
|
|
46
|
+
# Build a Hash from the given pairs, omitting nil values.
|
|
47
|
+
def self.omit_nil(hash)
|
|
48
|
+
result = {}
|
|
49
|
+
hash.each { |k, v| result[k] = v unless v.nil? }
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @api private
|
|
54
|
+
# Convert a value to its wire form: call +to_h+ on builder objects, leave
|
|
55
|
+
# Hashes/scalars untouched.
|
|
56
|
+
def self.wire(value)
|
|
57
|
+
return nil if value.nil?
|
|
58
|
+
return value.map { |v| wire(v) } if value.is_a?(Array)
|
|
59
|
+
return value.to_h if value.respond_to?(:to_h) && !value.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# =====================================================================
|
|
65
|
+
# ToolUse
|
|
66
|
+
# =====================================================================
|
|
67
|
+
class ToolUse
|
|
68
|
+
def initialize(name = nil)
|
|
69
|
+
@name = name
|
|
70
|
+
@id = nil
|
|
71
|
+
@arguments = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [self]
|
|
75
|
+
def with_id(id)
|
|
76
|
+
@id = id
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [self]
|
|
81
|
+
def with_name(name)
|
|
82
|
+
@name = name
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Accepts a JSON string (matching the Java API) or any value, which is
|
|
87
|
+
# serialised to a JSON string.
|
|
88
|
+
# @return [self]
|
|
89
|
+
def with_arguments(args)
|
|
90
|
+
@arguments = args.is_a?(String) ? args : JSON.generate(args)
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [Hash]
|
|
95
|
+
def to_h
|
|
96
|
+
LLM.omit_nil('id' => @id, 'name' => @name, 'arguments' => @arguments)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [ToolUse]
|
|
101
|
+
def self.tool_use(name = nil)
|
|
102
|
+
ToolUse.new(name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# =====================================================================
|
|
106
|
+
# Usage
|
|
107
|
+
# =====================================================================
|
|
108
|
+
class Usage
|
|
109
|
+
def initialize
|
|
110
|
+
@input_tokens = nil
|
|
111
|
+
@output_tokens = nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [self]
|
|
115
|
+
def with_input_tokens(input_tokens)
|
|
116
|
+
if !input_tokens.nil? && input_tokens.negative?
|
|
117
|
+
raise ArgumentError, 'inputTokens must be >= 0'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@input_tokens = input_tokens
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [self]
|
|
125
|
+
def with_output_tokens(output_tokens)
|
|
126
|
+
if !output_tokens.nil? && output_tokens.negative?
|
|
127
|
+
raise ArgumentError, 'outputTokens must be >= 0'
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@output_tokens = output_tokens
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [Hash]
|
|
135
|
+
def to_h
|
|
136
|
+
LLM.omit_nil('inputTokens' => @input_tokens, 'outputTokens' => @output_tokens)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Usage]
|
|
141
|
+
def self.usage
|
|
142
|
+
Usage.new
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Usage]
|
|
146
|
+
def self.input_tokens(count)
|
|
147
|
+
Usage.new.with_input_tokens(count)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @return [Usage]
|
|
151
|
+
def self.output_tokens(count)
|
|
152
|
+
Usage.new.with_output_tokens(count)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# =====================================================================
|
|
156
|
+
# StreamingPhysics
|
|
157
|
+
# timeToFirstToken serialises as a Delay: { timeUnit, value }
|
|
158
|
+
# =====================================================================
|
|
159
|
+
class StreamingPhysics
|
|
160
|
+
def initialize
|
|
161
|
+
@time_to_first_token = nil
|
|
162
|
+
@tokens_per_second = nil
|
|
163
|
+
@jitter = nil
|
|
164
|
+
@seed = nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Accepts a Delay-shaped Hash (+{ 'timeUnit' => .., 'value' => .. }+) or a
|
|
168
|
+
# (value, time_unit) pair.
|
|
169
|
+
# @return [self]
|
|
170
|
+
def with_time_to_first_token(value, time_unit = 'MILLISECONDS')
|
|
171
|
+
@time_to_first_token =
|
|
172
|
+
if value.is_a?(Hash)
|
|
173
|
+
{ 'timeUnit' => value['timeUnit'] || value[:timeUnit],
|
|
174
|
+
'value' => value['value'] || value[:value] }
|
|
175
|
+
else
|
|
176
|
+
{ 'timeUnit' => time_unit, 'value' => value }
|
|
177
|
+
end
|
|
178
|
+
self
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @return [self]
|
|
182
|
+
def with_tokens_per_second(tokens_per_second)
|
|
183
|
+
if !tokens_per_second.nil? && (tokens_per_second < 1 || tokens_per_second > 10_000)
|
|
184
|
+
raise ArgumentError, 'tokensPerSecond must be between 1 and 10000'
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
@tokens_per_second = tokens_per_second
|
|
188
|
+
self
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# @return [self]
|
|
192
|
+
def with_jitter(jitter)
|
|
193
|
+
if !jitter.nil? && (jitter < 0.0 || jitter > 1.0)
|
|
194
|
+
raise ArgumentError, 'jitter must be between 0.0 and 1.0'
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
@jitter = jitter
|
|
198
|
+
self
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @return [self]
|
|
202
|
+
def with_seed(seed)
|
|
203
|
+
@seed = seed
|
|
204
|
+
self
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @return [Hash]
|
|
208
|
+
def to_h
|
|
209
|
+
LLM.omit_nil(
|
|
210
|
+
'timeToFirstToken' => @time_to_first_token,
|
|
211
|
+
'tokensPerSecond' => @tokens_per_second,
|
|
212
|
+
'jitter' => @jitter,
|
|
213
|
+
'seed' => @seed
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [StreamingPhysics]
|
|
219
|
+
def self.streaming_physics
|
|
220
|
+
StreamingPhysics.new
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @return [StreamingPhysics]
|
|
224
|
+
def self.tokens_per_second(count)
|
|
225
|
+
StreamingPhysics.new.with_tokens_per_second(count)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# @return [StreamingPhysics]
|
|
229
|
+
def self.jitter(amount)
|
|
230
|
+
StreamingPhysics.new.with_jitter(amount)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Delay representing time-to-first-token: +{ 'timeUnit' => .., 'value' => .. }+.
|
|
234
|
+
# @return [Hash]
|
|
235
|
+
def self.time_to_first_token(value, time_unit = 'MILLISECONDS')
|
|
236
|
+
{ 'timeUnit' => time_unit, 'value' => value }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# =====================================================================
|
|
240
|
+
# Completion
|
|
241
|
+
# =====================================================================
|
|
242
|
+
class Completion
|
|
243
|
+
def initialize
|
|
244
|
+
@text = nil
|
|
245
|
+
@tool_calls = nil
|
|
246
|
+
@stop_reason = nil
|
|
247
|
+
@usage = nil
|
|
248
|
+
@streaming = nil
|
|
249
|
+
@streaming_physics = nil
|
|
250
|
+
@output_schema = nil
|
|
251
|
+
@model = nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# @return [self]
|
|
255
|
+
def with_text(text)
|
|
256
|
+
@text = text
|
|
257
|
+
self
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# @return [self]
|
|
261
|
+
def with_tool_call(tool_call)
|
|
262
|
+
@tool_calls ||= []
|
|
263
|
+
@tool_calls << tool_call
|
|
264
|
+
self
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# @return [self]
|
|
268
|
+
def with_tool_calls(*tool_calls)
|
|
269
|
+
flattened = tool_calls.length == 1 && tool_calls.first.is_a?(Array) ? tool_calls.first : tool_calls
|
|
270
|
+
@tool_calls = flattened.dup
|
|
271
|
+
self
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# @return [self]
|
|
275
|
+
def with_stop_reason(stop_reason)
|
|
276
|
+
@stop_reason = stop_reason
|
|
277
|
+
self
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @return [self]
|
|
281
|
+
def with_usage(usage)
|
|
282
|
+
@usage = usage
|
|
283
|
+
self
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# @return [self]
|
|
287
|
+
def with_streaming(streaming = true)
|
|
288
|
+
@streaming = streaming
|
|
289
|
+
self
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Enable streaming. Mirror of +completion.streaming()+.
|
|
293
|
+
# @return [self]
|
|
294
|
+
def streaming
|
|
295
|
+
with_streaming(true)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Note: does NOT touch +streaming+ (matching the Java/Python builder).
|
|
299
|
+
# @return [self]
|
|
300
|
+
def with_streaming_physics(physics)
|
|
301
|
+
@streaming_physics = physics
|
|
302
|
+
self
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Accepts a JSON string (matching Java) or any value (serialised to JSON).
|
|
306
|
+
# @return [self]
|
|
307
|
+
def with_output_schema(output_schema)
|
|
308
|
+
@output_schema = output_schema.is_a?(String) ? output_schema : JSON.generate(output_schema)
|
|
309
|
+
self
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# @return [self]
|
|
313
|
+
def with_model(model)
|
|
314
|
+
@model = model
|
|
315
|
+
self
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# @return [Hash]
|
|
319
|
+
def to_h
|
|
320
|
+
LLM.omit_nil(
|
|
321
|
+
'text' => @text,
|
|
322
|
+
'toolCalls' => LLM.wire(@tool_calls),
|
|
323
|
+
'stopReason' => @stop_reason,
|
|
324
|
+
'usage' => LLM.wire(@usage),
|
|
325
|
+
'streaming' => @streaming,
|
|
326
|
+
'streamingPhysics' => LLM.wire(@streaming_physics),
|
|
327
|
+
'outputSchema' => @output_schema,
|
|
328
|
+
'model' => @model
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# @return [Completion]
|
|
334
|
+
def self.completion
|
|
335
|
+
Completion.new
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# =====================================================================
|
|
339
|
+
# EmbeddingResponse
|
|
340
|
+
# =====================================================================
|
|
341
|
+
class EmbeddingResponse
|
|
342
|
+
def initialize
|
|
343
|
+
@dimensions = nil
|
|
344
|
+
@deterministic_from_input = nil
|
|
345
|
+
@seed = nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# @return [self]
|
|
349
|
+
def with_dimensions(dimensions)
|
|
350
|
+
@dimensions = dimensions
|
|
351
|
+
self
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# @return [self]
|
|
355
|
+
def with_deterministic_from_input(deterministic)
|
|
356
|
+
@deterministic_from_input = deterministic
|
|
357
|
+
self
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# @return [self]
|
|
361
|
+
def with_seed(seed)
|
|
362
|
+
@seed = seed
|
|
363
|
+
self
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# @return [Hash]
|
|
367
|
+
def to_h
|
|
368
|
+
LLM.omit_nil(
|
|
369
|
+
'dimensions' => @dimensions,
|
|
370
|
+
'deterministicFromInput' => @deterministic_from_input,
|
|
371
|
+
'seed' => @seed
|
|
372
|
+
)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# @return [EmbeddingResponse]
|
|
377
|
+
def self.embedding
|
|
378
|
+
EmbeddingResponse.new
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# =====================================================================
|
|
382
|
+
# IsolationSource — encodes as "kind:name" (e.g. "header:x-session-id")
|
|
383
|
+
# =====================================================================
|
|
384
|
+
class IsolationSource
|
|
385
|
+
attr_reader :kind, :name
|
|
386
|
+
|
|
387
|
+
def initialize(kind, name)
|
|
388
|
+
raise ArgumentError, 'name must not be nil or empty' if name.nil? || name.to_s.empty?
|
|
389
|
+
|
|
390
|
+
@kind = kind
|
|
391
|
+
@name = name
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# @return [String]
|
|
395
|
+
def encode
|
|
396
|
+
"#{@kind}:#{@name}"
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# @return [IsolationSource]
|
|
401
|
+
def self.header(name)
|
|
402
|
+
IsolationSource.new('header', name)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# @return [IsolationSource]
|
|
406
|
+
def self.query_parameter(name)
|
|
407
|
+
IsolationSource.new('query_parameter', name)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# @return [IsolationSource]
|
|
411
|
+
def self.cookie(name)
|
|
412
|
+
IsolationSource.new('cookie', name)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# @api private
|
|
416
|
+
def self.post_matcher(path)
|
|
417
|
+
{ 'method' => 'POST', 'path' => path }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# @api private
|
|
421
|
+
def self.build_llm_response(provider, model, completion, embedding, conversation_predicates, chaos)
|
|
422
|
+
omit_nil(
|
|
423
|
+
'provider' => provider,
|
|
424
|
+
'model' => model,
|
|
425
|
+
'completion' => wire(completion),
|
|
426
|
+
'embedding' => wire(embedding),
|
|
427
|
+
'conversationPredicates' => wire(conversation_predicates),
|
|
428
|
+
'chaos' => wire(chaos)
|
|
429
|
+
)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# @api private
|
|
433
|
+
# Wraps a Hash so it responds to +to_h+, allowing it to be passed to
|
|
434
|
+
# +Client#upsert+ (which serialises via +to_h+).
|
|
435
|
+
class RawExpectation
|
|
436
|
+
def initialize(hash)
|
|
437
|
+
@hash = hash
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def to_h
|
|
441
|
+
@hash
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# =====================================================================
|
|
446
|
+
# LlmMockBuilder — a single completion or embedding mock.
|
|
447
|
+
# =====================================================================
|
|
448
|
+
class LlmMockBuilder
|
|
449
|
+
def initialize(path)
|
|
450
|
+
@path = path
|
|
451
|
+
@provider = nil
|
|
452
|
+
@model = nil
|
|
453
|
+
@completion = nil
|
|
454
|
+
@embedding = nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# @return [self]
|
|
458
|
+
def with_provider(provider)
|
|
459
|
+
@provider = provider
|
|
460
|
+
self
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# @return [self]
|
|
464
|
+
def with_model(model)
|
|
465
|
+
@model = model
|
|
466
|
+
self
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# @param response [Completion, EmbeddingResponse]
|
|
470
|
+
# @return [self]
|
|
471
|
+
def responding_with(response)
|
|
472
|
+
if response.is_a?(EmbeddingResponse)
|
|
473
|
+
@embedding = response
|
|
474
|
+
@completion = nil
|
|
475
|
+
else
|
|
476
|
+
@completion = response
|
|
477
|
+
@embedding = nil
|
|
478
|
+
end
|
|
479
|
+
self
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# @return [Hash] a single expectation
|
|
483
|
+
def build
|
|
484
|
+
{
|
|
485
|
+
'httpRequest' => LLM.post_matcher(@path),
|
|
486
|
+
'httpLlmResponse' => LLM.build_llm_response(@provider, @model, @completion, @embedding, nil, nil)
|
|
487
|
+
}
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Build and register the expectation via +client.upsert+.
|
|
491
|
+
# @return [Array<Expectation>]
|
|
492
|
+
def apply_to(client)
|
|
493
|
+
client.upsert(RawExpectation.new(build))
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Entry point mirroring +LlmMockBuilder.llmMock(path)+.
|
|
498
|
+
# @return [LlmMockBuilder]
|
|
499
|
+
def self.llm_mock(path)
|
|
500
|
+
LlmMockBuilder.new(path)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# =====================================================================
|
|
504
|
+
# TurnBuilder — one turn within a conversation.
|
|
505
|
+
# =====================================================================
|
|
506
|
+
class TurnBuilder
|
|
507
|
+
attr_reader :chaos, :completion
|
|
508
|
+
|
|
509
|
+
def initialize(parent)
|
|
510
|
+
@parent = parent
|
|
511
|
+
@turn_index = nil
|
|
512
|
+
@latest_message_contains = nil
|
|
513
|
+
@latest_message_matches = nil
|
|
514
|
+
@latest_message_role = nil
|
|
515
|
+
@contains_tool_result_for = nil
|
|
516
|
+
@semantic_match_against = nil
|
|
517
|
+
@normalization = nil
|
|
518
|
+
@chaos = nil
|
|
519
|
+
@completion = nil
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# @return [self]
|
|
523
|
+
def when_turn_index(index)
|
|
524
|
+
@turn_index = index
|
|
525
|
+
self
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# @return [self]
|
|
529
|
+
def when_latest_message_contains(text)
|
|
530
|
+
@latest_message_contains = text
|
|
531
|
+
self
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# @return [self]
|
|
535
|
+
def when_latest_message_matches(regex)
|
|
536
|
+
raise ArgumentError, 'regex must not be nil' if regex.nil?
|
|
537
|
+
|
|
538
|
+
@latest_message_matches = regex.is_a?(Regexp) ? regex.source : regex
|
|
539
|
+
self
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# @return [self]
|
|
543
|
+
def when_latest_message_role(role)
|
|
544
|
+
@latest_message_role = role
|
|
545
|
+
self
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# @return [self]
|
|
549
|
+
def when_contains_tool_result_for(tool_name)
|
|
550
|
+
@contains_tool_result_for = tool_name
|
|
551
|
+
self
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# @return [self]
|
|
555
|
+
def when_semantic_match(expected_meaning)
|
|
556
|
+
@semantic_match_against = expected_meaning
|
|
557
|
+
self
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# @return [self]
|
|
561
|
+
def with_normalization(normalization)
|
|
562
|
+
@normalization = normalization
|
|
563
|
+
self
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# @return [self]
|
|
567
|
+
def with_chaos(chaos)
|
|
568
|
+
@chaos = chaos
|
|
569
|
+
self
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# @return [self]
|
|
573
|
+
def responding_with(completion)
|
|
574
|
+
@completion = completion
|
|
575
|
+
self
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Start a new turn on the parent conversation builder.
|
|
579
|
+
# @return [TurnBuilder]
|
|
580
|
+
def turn
|
|
581
|
+
@parent.turn
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Return to the parent conversation builder.
|
|
585
|
+
# @return [LlmConversationBuilder]
|
|
586
|
+
def and_then
|
|
587
|
+
@parent
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# @return [Array<Hash>]
|
|
591
|
+
def build
|
|
592
|
+
@parent.build
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# @return [Array<Expectation>]
|
|
596
|
+
def apply_to(client)
|
|
597
|
+
@parent.apply_to(client)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# @api private
|
|
601
|
+
# @return [Hash, nil] the conversationPredicates Hash, or nil if none set.
|
|
602
|
+
def predicates
|
|
603
|
+
return nil unless any_predicate?
|
|
604
|
+
|
|
605
|
+
LLM.omit_nil(
|
|
606
|
+
'turnIndex' => @turn_index,
|
|
607
|
+
'latestMessageContains' => @latest_message_contains,
|
|
608
|
+
'latestMessageMatches' => @latest_message_matches,
|
|
609
|
+
'latestMessageRole' => @latest_message_role,
|
|
610
|
+
'containsToolResultFor' => @contains_tool_result_for,
|
|
611
|
+
'semanticMatchAgainst' => @semantic_match_against,
|
|
612
|
+
'normalization' => LLM.wire(@normalization)
|
|
613
|
+
)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# @api private
|
|
617
|
+
# +normalization+ is intentionally excluded (a modifier, not a predicate).
|
|
618
|
+
def any_predicate?
|
|
619
|
+
!@turn_index.nil? ||
|
|
620
|
+
!@latest_message_contains.nil? ||
|
|
621
|
+
!@latest_message_matches.nil? ||
|
|
622
|
+
!@latest_message_role.nil? ||
|
|
623
|
+
!@contains_tool_result_for.nil? ||
|
|
624
|
+
!@semantic_match_against.nil?
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# =====================================================================
|
|
629
|
+
# LlmConversationBuilder — multi-turn conversation with scenario state.
|
|
630
|
+
# =====================================================================
|
|
631
|
+
SCENARIO_PREFIX = '__llm_conv_'
|
|
632
|
+
ISOLATION_MARKER = '__iso='
|
|
633
|
+
DONE_STATE = '__done'
|
|
634
|
+
|
|
635
|
+
class LlmConversationBuilder
|
|
636
|
+
def initialize
|
|
637
|
+
@path = nil
|
|
638
|
+
@provider = nil
|
|
639
|
+
@model = nil
|
|
640
|
+
@isolation_source = nil
|
|
641
|
+
@turns = []
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# @return [self]
|
|
645
|
+
def with_path(path)
|
|
646
|
+
@path = path
|
|
647
|
+
self
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# @return [self]
|
|
651
|
+
def with_provider(provider)
|
|
652
|
+
@provider = provider
|
|
653
|
+
self
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# @return [self]
|
|
657
|
+
def with_model(model)
|
|
658
|
+
@model = model
|
|
659
|
+
self
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# @return [self]
|
|
663
|
+
def isolate_by(source)
|
|
664
|
+
@isolation_source = source
|
|
665
|
+
self
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# @return [TurnBuilder]
|
|
669
|
+
def turn
|
|
670
|
+
turn_builder = TurnBuilder.new(self)
|
|
671
|
+
@turns << turn_builder
|
|
672
|
+
turn_builder
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# @return [Array<Hash>] a list of expectations
|
|
676
|
+
def build
|
|
677
|
+
raise ArgumentError, 'At least one turn must be defined' if @turns.empty?
|
|
678
|
+
raise ArgumentError, 'Path must be set' if @path.nil?
|
|
679
|
+
raise ArgumentError, 'Provider must be set' if @provider.nil?
|
|
680
|
+
|
|
681
|
+
conversation_id = SCENARIO_PREFIX + SecureRandom.uuid
|
|
682
|
+
scenario_name = conversation_id
|
|
683
|
+
if @isolation_source
|
|
684
|
+
scenario_name = conversation_id + ISOLATION_MARKER + @isolation_source.encode
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
n = @turns.length
|
|
688
|
+
@turns.each_with_index.map do |turn, i|
|
|
689
|
+
next_state = i < n - 1 ? "turn_#{i + 1}" : DONE_STATE
|
|
690
|
+
llm_response = LLM.build_llm_response(
|
|
691
|
+
@provider, @model, turn.completion, nil, turn.predicates, turn.chaos
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
{
|
|
695
|
+
'httpRequest' => LLM.post_matcher(@path),
|
|
696
|
+
'scenarioName' => scenario_name,
|
|
697
|
+
'scenarioState' => i.zero? ? 'Started' : "turn_#{i}",
|
|
698
|
+
'newScenarioState' => next_state,
|
|
699
|
+
'httpLlmResponse' => llm_response
|
|
700
|
+
}
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# @return [Array<Expectation>]
|
|
705
|
+
def apply_to(client)
|
|
706
|
+
client.upsert(*build.map { |h| RawExpectation.new(h) })
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Entry point mirroring +LlmConversationBuilder.conversation()+.
|
|
711
|
+
# @return [LlmConversationBuilder]
|
|
712
|
+
def self.conversation
|
|
713
|
+
LlmConversationBuilder.new
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# =====================================================================
|
|
717
|
+
# LlmFailoverBuilder — N failures then a success completion.
|
|
718
|
+
# =====================================================================
|
|
719
|
+
def self.default_error_body(status_code)
|
|
720
|
+
type, message =
|
|
721
|
+
case status_code
|
|
722
|
+
when 429
|
|
723
|
+
['rate_limit_error', 'Rate limit exceeded. Please retry after a brief wait.']
|
|
724
|
+
when 500
|
|
725
|
+
['internal_server_error', 'An internal error occurred. Please retry your request.']
|
|
726
|
+
when 502
|
|
727
|
+
['bad_gateway', 'Bad gateway. The upstream server returned an invalid response.']
|
|
728
|
+
when 503
|
|
729
|
+
['service_unavailable', 'The service is temporarily overloaded. Please retry later.']
|
|
730
|
+
else
|
|
731
|
+
['error', "Request failed with status #{status_code}"]
|
|
732
|
+
end
|
|
733
|
+
JSON.generate('error' => { 'type' => type, 'message' => message })
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# @api private
|
|
737
|
+
def self.validate_status_code(status_code)
|
|
738
|
+
if status_code < 100 || status_code > 599
|
|
739
|
+
raise ArgumentError, "statusCode must be between 100 and 599, got #{status_code}"
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
class LlmFailoverBuilder
|
|
744
|
+
def initialize
|
|
745
|
+
@path = nil
|
|
746
|
+
@provider = nil
|
|
747
|
+
@model = nil
|
|
748
|
+
@failures = []
|
|
749
|
+
@success_completion = nil
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# @return [self]
|
|
753
|
+
def with_path(path)
|
|
754
|
+
@path = path
|
|
755
|
+
self
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# @return [self]
|
|
759
|
+
def with_provider(provider)
|
|
760
|
+
@provider = provider
|
|
761
|
+
self
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# @return [self]
|
|
765
|
+
def with_model(model)
|
|
766
|
+
@model = model
|
|
767
|
+
self
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# Add one (or +count+) failure attempt(s) with the given status.
|
|
771
|
+
#
|
|
772
|
+
# Mirrors the three overloads: +fail_with(status)+,
|
|
773
|
+
# +fail_with(status, error_body_string)+ and +fail_with(status, count_int)+.
|
|
774
|
+
# @return [self]
|
|
775
|
+
def fail_with(status_code, second = nil)
|
|
776
|
+
LLM.validate_status_code(status_code)
|
|
777
|
+
if second.is_a?(Integer)
|
|
778
|
+
raise ArgumentError, "count must be >= 1, got #{second}" if second < 1
|
|
779
|
+
|
|
780
|
+
second.times { @failures << { status_code: status_code, error_body: nil } }
|
|
781
|
+
else
|
|
782
|
+
@failures << { status_code: status_code, error_body: second.is_a?(String) ? second : nil }
|
|
783
|
+
end
|
|
784
|
+
self
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
# @return [self]
|
|
788
|
+
def then_respond_with(completion)
|
|
789
|
+
@success_completion = completion
|
|
790
|
+
self
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# @return [Integer]
|
|
794
|
+
def failure_count
|
|
795
|
+
@failures.length
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# @api private
|
|
799
|
+
def coalesce_failures
|
|
800
|
+
result = []
|
|
801
|
+
@failures.each do |spec|
|
|
802
|
+
last = result.last
|
|
803
|
+
if last && last[:status_code] == spec[:status_code] && last[:error_body] == spec[:error_body]
|
|
804
|
+
last[:count] += 1
|
|
805
|
+
else
|
|
806
|
+
result << { status_code: spec[:status_code], error_body: spec[:error_body], count: 1 }
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
result
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# @return [Array<Hash>] a list of expectations
|
|
813
|
+
def build
|
|
814
|
+
raise ArgumentError, 'Path must be set' if @path.nil?
|
|
815
|
+
raise ArgumentError, 'Provider must be set' if @provider.nil?
|
|
816
|
+
raise ArgumentError, 'At least one failure must be defined' if @failures.empty?
|
|
817
|
+
raise ArgumentError, 'Success completion must be set via then_respond_with()' if @success_completion.nil?
|
|
818
|
+
|
|
819
|
+
expectations = coalesce_failures.map do |cf|
|
|
820
|
+
body = cf[:error_body] || LLM.default_error_body(cf[:status_code])
|
|
821
|
+
{
|
|
822
|
+
'httpRequest' => LLM.post_matcher(@path),
|
|
823
|
+
'times' => { 'remainingTimes' => cf[:count], 'unlimited' => false },
|
|
824
|
+
'timeToLive' => { 'unlimited' => true },
|
|
825
|
+
'httpResponse' => {
|
|
826
|
+
'statusCode' => cf[:status_code],
|
|
827
|
+
'headers' => [{ 'name' => 'Content-Type', 'values' => ['application/json'] }],
|
|
828
|
+
'body' => body
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
expectations << {
|
|
834
|
+
'httpRequest' => LLM.post_matcher(@path),
|
|
835
|
+
'times' => { 'remainingTimes' => 0, 'unlimited' => true },
|
|
836
|
+
'timeToLive' => { 'unlimited' => true },
|
|
837
|
+
'httpLlmResponse' => LLM.build_llm_response(@provider, @model, @success_completion, nil, nil, nil)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
expectations
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# @return [Array<Expectation>]
|
|
844
|
+
def apply_to(client)
|
|
845
|
+
client.upsert(*build.map { |h| RawExpectation.new(h) })
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# Entry point mirroring +LlmFailoverBuilder.llmFailover()+.
|
|
850
|
+
# @return [LlmFailoverBuilder]
|
|
851
|
+
def self.llm_failover
|
|
852
|
+
LlmFailoverBuilder.new
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
end
|