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.
@@ -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