pwn 0.5.757 → 0.5.758

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9bcba2a6a58d7e3f7990a1dbbf0cc73a1234efe20bfd7dd0eae786ecc4c4c32
4
- data.tar.gz: 252972a638225c5ddd3693839eb4fe7db2311529e3ceb2a32f69f0cc3056fd27
3
+ metadata.gz: 004ad4f77955698492a48591db3dad3f54d141fb94ddb7952211e86dc4945526
4
+ data.tar.gz: 5c76e0a2f71fdc1afd13f8af828c2027cb8183f3e73612d81f220b864b6df636
5
5
  SHA512:
6
- metadata.gz: e581b4c0dd6a4b1e05beccfade96bc635f48bf47c63168209b4900e6fb68a9d8e0b2394b4f6458a5aa96a9373248a0f96adb9ae0dcdeea2210f10f908bae630c
7
- data.tar.gz: c87a11936f1cf59e2d6373c2d941ccf33f5aa4edac9e91fa09e1c9b5b871bcf2def227c10a6659131bc4279e05c7b6d85e28eeb23ff701d3ebadb0a3359ba129
6
+ metadata.gz: ffe44c731211488e5d07938e29dc1c503307801de30d0c2effc55d139d0f00b0a40a7deeda375f05b581ea88e379b9b82f031b589236bd5a08edd590078b3511
7
+ data.tar.gz: 9c8275fd41e044aed262bcbe7fcf9fe00694b43227030ba1ce90d907c6e68b54efa53dacd3c3cb712890acc159005f5ecd4c9cf1caf065de8032cfc4123041de
data/lib/pwn/ai/ollama.rb CHANGED
@@ -436,6 +436,9 @@ module PWN
436
436
  # - function.arguments must be a Hash/Array object, not a JSON string
437
437
  # (string args → HTTP 400 "can't find closing '}' symbol")
438
438
  # - assistant content nil + tool_calls → "" (Open WebUI form validation)
439
+ # - system/developer rows after the first message are merged into one
440
+ # leading system message. Qwen-style templates raise HTTP 500
441
+ # "System message must be at the beginning" otherwise.
439
442
  private_class_method def self.parse_tool_arguments_object(opts = {})
440
443
  raw = opts[:arguments]
441
444
  case raw
@@ -458,7 +461,7 @@ module PWN
458
461
  end
459
462
 
460
463
  private_class_method def self.normalize_messages_for_ollama(opts = {})
461
- Array(opts[:messages]).filter_map do |m|
464
+ rows = Array(opts[:messages]).filter_map do |m|
462
465
  next unless m.is_a?(Hash)
463
466
 
464
467
  role = (m[:role] || m['role']).to_s
@@ -508,6 +511,71 @@ module PWN
508
511
 
509
512
  out
510
513
  end
514
+ lead_system(messages: rows, fold_into_user: opts[:fold_into_user])
515
+ end
516
+
517
+ # One system role is only safe when the server does not already prepend one.
518
+ # Models whose template raises "System message must be at the beginning"
519
+ # already carry a Modelfile SYSTEM prompt. Fold client system text into
520
+ # the first user message instead of sending a second system role.
521
+ private_class_method def self.lead_system(opts = {})
522
+ parts = []
523
+ rest = []
524
+ Array(opts[:messages]).each do |row|
525
+ next unless row.is_a?(Hash)
526
+
527
+ role = (row[:role] || row['role']).to_s
528
+ if %w[system developer].include?(role)
529
+ text = (row.key?(:content) ? row[:content] : row['content']).to_s
530
+ parts << text unless text.strip.empty?
531
+ else
532
+ rest << row
533
+ end
534
+ end
535
+ return rest if parts.empty?
536
+
537
+ text = parts.join("\n\n")
538
+ return [{ role: 'system', content: text }] + rest unless opts[:fold_into_user]
539
+
540
+ user = rest.find { |row| (row[:role] || row['role']).to_s == 'user' }
541
+ if user
542
+ body = (user.key?(:content) ? user[:content] : user['content']).to_s
543
+ user[:content] = "#{text}\n\n#{body}"
544
+ user.delete('content')
545
+ rest
546
+ else
547
+ [{ role: 'user', content: text }] + rest
548
+ end
549
+ end
550
+
551
+ private_class_method def self.strict_system_template?(opts = {})
552
+ name = opts[:model].to_s.strip
553
+ return false if name.empty?
554
+
555
+ @strict_system_cache ||= {}
556
+ return @strict_system_cache[name] if @strict_system_cache.key?(name)
557
+
558
+ @strict_system_cache[name] = template_rejects_later_system?(model: name)
559
+ rescue StandardError
560
+ false
561
+ end
562
+
563
+ private_class_method def self.template_rejects_later_system?(opts = {})
564
+ name = opts[:model].to_s
565
+ names = [name]
566
+ names << "#{name}:latest" unless name.include?(':')
567
+ names.each do |candidate|
568
+ raw = ollama_rest_call(
569
+ http_method: :post, rest_call: 'api/show',
570
+ http_body: { model: candidate }, timeout: 8, quiet: true, non_interactive: true
571
+ )
572
+ template = PWN::AI::ModelCatalog.parse_row(raw: raw)
573
+ text = template.is_a?(Hash) ? (template[:template] || template['template']).to_s : ''
574
+ return true if text.include?('System message must be at the beginning')
575
+ end
576
+ false
577
+ rescue StandardError
578
+ false
511
579
  end
512
580
 
513
581
  # Supported Method Parameters::
@@ -526,13 +594,16 @@ module PWN
526
594
  # NDJSON chunks back into a single response.
527
595
 
528
596
  public_class_method def self.chat_with_tools(opts = {})
529
- engine = PWN::Env[:ai][:ollama]
530
- messages = normalize_messages_for_ollama(messages: opts[:messages])
531
- raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
532
-
597
+ engine = PWN::Env[:ai][:ollama]
533
598
  model = opts[:model] ||= engine[:model]
534
599
  raise 'ERROR: Model is required. Call #get_models method for details' if model.nil?
535
600
 
601
+ messages = normalize_messages_for_ollama(
602
+ messages: opts[:messages],
603
+ fold_into_user: strict_system_template?(model: model)
604
+ )
605
+ raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
606
+
536
607
  temp = opts[:temp].to_f
537
608
  temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
538
609
 
@@ -656,6 +727,10 @@ module PWN
656
727
  end
657
728
 
658
729
  http_body[:messages].push(user_role)
730
+ http_body[:messages] = lead_system(
731
+ messages: http_body[:messages],
732
+ fold_into_user: strict_system_template?(model: model)
733
+ )
659
734
 
660
735
  timeout = opts[:timeout]
661
736
  spinner = opts[:spinner]
@@ -468,6 +468,9 @@ module PWN
468
468
  # - function.arguments must be a Hash/Array object, not a JSON string
469
469
  # (string args → HTTP 400 "can't find closing '}' symbol")
470
470
  # - assistant content nil + tool_calls → "" (Open WebUI form validation)
471
+ # - system/developer rows after the first message are merged into one
472
+ # leading system message. Qwen-style templates raise HTTP 500
473
+ # "System message must be at the beginning" otherwise.
471
474
  private_class_method def self.parse_tool_arguments_object(opts = {})
472
475
  raw = opts[:arguments]
473
476
  case raw
@@ -490,7 +493,7 @@ module PWN
490
493
  end
491
494
 
492
495
  private_class_method def self.normalize_messages_for_ollama(opts = {})
493
- Array(opts[:messages]).filter_map do |m|
496
+ rows = Array(opts[:messages]).filter_map do |m|
494
497
  next unless m.is_a?(Hash)
495
498
 
496
499
  role = (m[:role] || m['role']).to_s
@@ -540,6 +543,71 @@ module PWN
540
543
 
541
544
  out
542
545
  end
546
+ lead_system(messages: rows, fold_into_user: opts[:fold_into_user])
547
+ end
548
+
549
+ # One system role is only safe when the server does not already prepend one.
550
+ # mini-mythos-mission-ready has a Modelfile SYSTEM prompt and a template that
551
+ # raises if any later message has role system. Fold those rows into the
552
+ # first user message instead of sending a second system role.
553
+ private_class_method def self.lead_system(opts = {})
554
+ parts = []
555
+ rest = []
556
+ Array(opts[:messages]).each do |row|
557
+ next unless row.is_a?(Hash)
558
+
559
+ role = (row[:role] || row['role']).to_s
560
+ if %w[system developer].include?(role)
561
+ text = (row.key?(:content) ? row[:content] : row['content']).to_s
562
+ parts << text unless text.strip.empty?
563
+ else
564
+ rest << row
565
+ end
566
+ end
567
+ return rest if parts.empty?
568
+
569
+ text = parts.join("\n\n")
570
+ return [{ role: 'system', content: text }] + rest unless opts[:fold_into_user]
571
+
572
+ user = rest.find { |row| (row[:role] || row['role']).to_s == 'user' }
573
+ if user
574
+ body = (user.key?(:content) ? user[:content] : user['content']).to_s
575
+ user[:content] = "#{text}\n\n#{body}"
576
+ user.delete('content')
577
+ rest
578
+ else
579
+ [{ role: 'user', content: text }] + rest
580
+ end
581
+ end
582
+
583
+ private_class_method def self.strict_system_template?(opts = {})
584
+ name = opts[:model].to_s.strip
585
+ return false if name.empty?
586
+
587
+ @strict_system_cache ||= {}
588
+ return @strict_system_cache[name] if @strict_system_cache.key?(name)
589
+
590
+ @strict_system_cache[name] = template_rejects_later_system?(model: name)
591
+ rescue StandardError
592
+ false
593
+ end
594
+
595
+ private_class_method def self.template_rejects_later_system?(opts = {})
596
+ name = opts[:model].to_s
597
+ names = [name]
598
+ names << "#{name}:latest" unless name.include?(':')
599
+ names.each do |candidate|
600
+ raw = openwebui_rest_call(
601
+ http_method: :post, rest_call: 'ollama/api/show',
602
+ http_body: { model: candidate }, timeout: 8, quiet: true, non_interactive: true
603
+ )
604
+ template = PWN::AI::ModelCatalog.parse_row(raw: raw)
605
+ text = template.is_a?(Hash) ? (template[:template] || template['template']).to_s : ''
606
+ return true if text.include?('System message must be at the beginning')
607
+ end
608
+ false
609
+ rescue StandardError
610
+ false
543
611
  end
544
612
 
545
613
  # Supported Method Parameters::
@@ -559,13 +627,16 @@ module PWN
559
627
  # Bare POST /api/chat is not an API route on stock Open WebUI (405).
560
628
 
561
629
  public_class_method def self.chat_with_tools(opts = {})
562
- engine = PWN::Env[:ai][:openwebui]
563
- messages = normalize_messages_for_ollama(messages: opts[:messages])
564
- raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
565
-
630
+ engine = PWN::Env[:ai][:openwebui]
566
631
  model = opts[:model] ||= engine[:model]
567
632
  raise 'ERROR: Model is required. Call #get_models method for details' unless real_config_value?(value: model)
568
633
 
634
+ messages = normalize_messages_for_ollama(
635
+ messages: opts[:messages],
636
+ fold_into_user: strict_system_template?(model: model)
637
+ )
638
+ raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
639
+
569
640
  temp = opts[:temp].to_f
570
641
  temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
571
642
 
@@ -682,6 +753,10 @@ module PWN
682
753
  end
683
754
 
684
755
  http_body[:messages].push(user_role)
756
+ http_body[:messages] = lead_system(
757
+ messages: http_body[:messages],
758
+ fold_into_user: strict_system_template?(model: model)
759
+ )
685
760
 
686
761
  timeout = opts[:timeout]
687
762
  spinner = opts[:spinner]
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.757'
4
+ VERSION = '0.5.758'
5
5
  end
@@ -12,10 +12,11 @@ describe PWN::AI::Ollama do
12
12
  allow(described_class).to receive(:sleep)
13
13
  allow(described_class).to receive(:rand).and_return(0.5)
14
14
  allow(PWN::AI::HttpRetry).to receive(:report_event)
15
+ described_class.instance_variable_set(:@strict_system_cache, nil)
15
16
  expect do
16
17
  described_class.chat_with_tools(messages: [{ role: 'user', content: 'hello' }], quiet: quiet)
17
18
  end.to raise_error(RuntimeError, /Ollama.*429.*rate limit exceeded/)
18
- expect(RestClient::Request).to have_received(:execute).exactly(5).times
19
+ expect(RestClient::Request).to have_received(:execute).exactly(6).times
19
20
  expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
20
21
  end
21
22
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'Ollama and Open WebUI system-message placement' do
6
+ it 'puts a later Ollama system message first so the chat template does not 500' do
7
+ engine = { model: 'mini-mythos-mission-ready', num_ctx: 2048, keep_alive: '1m', temp: 0.1, tool_temp: 0.1 }
8
+ allow(PWN::Env).to receive(:[]).and_call_original
9
+ allow(PWN::Env).to receive(:[]).with(:ai).and_return({ ollama: engine, active: 'ollama' })
10
+ captured = nil
11
+ allow(PWN::AI::Ollama).to receive(:ollama_rest_call) do |**kwargs|
12
+ captured = kwargs[:http_body]
13
+ '{"message":{"role":"assistant","content":"ok"}}'
14
+ end
15
+ PWN::AI::Ollama.chat_with_tools(
16
+ model: 'mini-mythos-mission-ready',
17
+ messages: [
18
+ { role: 'user', content: 'hello' },
19
+ { role: 'system', content: 'must lead' },
20
+ { role: 'developer', content: 'also lead' }
21
+ ]
22
+ )
23
+ expect(captured[:messages].map { |row| row[:role] }).to eq(%w[system user])
24
+ expect(captured[:messages][0][:content]).to include('must lead', 'also lead')
25
+ end
26
+
27
+ it 'folds Open WebUI system text into the user message when the template rejects a second system role' do
28
+ engine = { model: 'mini-mythos-mission-ready:latest', num_ctx: 2048, keep_alive: '1m', temp: 0.1, tool_temp: 0.1 }
29
+ allow(PWN::Env).to receive(:[]).and_call_original
30
+ allow(PWN::Env).to receive(:[]).with(:ai).and_return({ openwebui: engine, active: 'openwebui' })
31
+ PWN::AI::OpenWebUI.instance_variable_set(:@strict_system_cache, nil)
32
+ captured = nil
33
+ allow(PWN::AI::OpenWebUI).to receive(:openwebui_rest_call) do |**kwargs|
34
+ if kwargs[:rest_call].to_s.end_with?('show')
35
+ { template: "raise_exception('System message must be at the beginning.')" }.to_json
36
+ else
37
+ captured = kwargs[:http_body]
38
+ '{"message":{"role":"assistant","content":"ok"}}'
39
+ end
40
+ end
41
+ PWN::AI::OpenWebUI.chat_with_tools(
42
+ model: 'mini-mythos-mission-ready:latest',
43
+ messages: [
44
+ { role: 'system', content: 'operator rules' },
45
+ { role: 'user', content: 'ping' },
46
+ { role: 'system', content: 'ENGAGEMENT MEMORY' }
47
+ ]
48
+ )
49
+ expect(captured[:messages].map { |row| row[:role] }).to eq(%w[user])
50
+ expect(captured[:messages][0][:content]).to include('operator rules', 'ENGAGEMENT MEMORY', 'ping')
51
+ end
52
+ end
@@ -12,10 +12,11 @@ describe PWN::AI::OpenWebUI do
12
12
  allow(described_class).to receive(:sleep)
13
13
  allow(described_class).to receive(:rand).and_return(0.5)
14
14
  allow(PWN::AI::HttpRetry).to receive(:report_event)
15
+ described_class.instance_variable_set(:@strict_system_cache, nil)
15
16
  expect do
16
17
  described_class.chat_with_tools(messages: [{ role: 'user', content: 'hello' }], quiet: quiet)
17
18
  end.to raise_error(RuntimeError, /Open WebUI.*429.*rate limit exceeded/)
18
- expect(RestClient::Request).to have_received(:execute).exactly(5).times
19
+ expect(RestClient::Request).to have_received(:execute).exactly(6).times
19
20
  expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
20
21
  end
21
22
  end
@@ -83,6 +84,33 @@ describe PWN::AI::OpenWebUI do
83
84
  expect(captured_chat[:stream]).to be true
84
85
  end
85
86
 
87
+ it 'merges later system and developer messages into one leading system message' do
88
+ engine = { model: 'mini-mythos-mission-ready', num_ctx: 2048, keep_alive: '1m', temp: 0.1, tool_temp: 0.1 }
89
+ allow(PWN::Env).to receive(:[]).and_call_original
90
+ allow(PWN::Env).to receive(:[]).with(:ai).and_return({ openwebui: engine, active: 'openwebui' })
91
+ captured = nil
92
+ allow(described_class).to receive(:openwebui_rest_call) do |**kwargs|
93
+ captured = kwargs[:http_body]
94
+ '{"message":{"role":"assistant","content":"ok"}}'
95
+ end
96
+ described_class.chat_with_tools(
97
+ messages: [
98
+ { role: 'system', content: 'operator rules' },
99
+ { role: 'user', content: 'hello' },
100
+ { role: 'system', content: 'ENGAGEMENT MEMORY\nnotes' },
101
+ { role: 'developer', content: 'mid-turn reminder' },
102
+ { role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'shell', arguments: '{"cmd":"id"}' } }] }
103
+ ],
104
+ model: 'mini-mythos-mission-ready'
105
+ )
106
+ roles = captured[:messages].map { |row| row[:role] }
107
+ expect(roles).to eq(%w[system user assistant])
108
+ expect(captured[:messages][0][:content]).to include('operator rules')
109
+ expect(captured[:messages][0][:content]).to include('ENGAGEMENT MEMORY')
110
+ expect(captured[:messages][0][:content]).to include('mid-turn reminder')
111
+ expect(captured[:messages][2][:tool_calls].first[:function][:arguments]).to eq({ cmd: 'id' })
112
+ end
113
+
86
114
  it 'assembles native /api/chat NDJSON stream into a single message' do
87
115
  body = [
88
116
  { model: 'm', message: { role: 'assistant', content: 'Hel' }, done: false }.to_json,
@@ -1201,6 +1201,7 @@
1201
1201
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.get_model Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.get_model`: Supported Method Parameters\n\nmodel = PWN::AI::Ollama.get_model(name: ‘required - tag name’)\n\nLocal tags have size and family, not token prices. get_model reads /api/show for that tag and returns it. Missing pricing stays missing.\n"}]}
1202
1202
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.get_models Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.get_models`: Supported Method Parameters\n\nresponse = PWN::AI::Ollama.get_models\n"}]}
1203
1203
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.help Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.help`: "}]}
1204
+ {"messages":[{"role":"user","content":"PWN::AI::Ollama.lead_system Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.lead_system`: "}]}
1204
1205
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.normalize_messages_for_ollama Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.normalize_messages_for_ollama`: "}]}
1205
1206
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.ollama_rest_call Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.ollama_rest_call`: Supported Method Parameters\n\nollama_rest_call(\n\nhttp_method: 'optional HTTP method (defaults to GET)',\nrest_call: 'required rest call path relative to base_uri (e.g. api/chat)',\nparams: 'optional params passed in the URI or HTTP Headers',\nhttp_body: 'optional HTTP body sent in HTTP methods that support it e.g. POST',\ntimeout: 'optional timeout in seconds (defaults to 900)',\nspinner: 'optional - display spinner (defaults to false)'\n\n)\n"}]}
1206
1207
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.parse_tool_arguments_object Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.parse_tool_arguments_object`: "}]}
@@ -1252,6 +1253,7 @@
1252
1253
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.get_model Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.get_model`: Supported Method Parameters\n\nmodel = PWN::AI::OpenWebUI.get_model(name: ‘required - model id’)\n\nOpen WebUI mixes OpenAI-compat rows and proxied Ollama tags. A direct model route is tried first; otherwise the already-fetched catalog row is returned. Local tags without a price stay unpriced.\n"}]}
1253
1254
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.get_models Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.get_models`: Supported Method Parameters\n\nresponse = PWN::AI::OpenWebUI.get_models\n"}]}
1254
1255
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.help Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.help`: "}]}
1256
+ {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.lead_system Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.lead_system`: "}]}
1255
1257
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.normalize_messages_for_ollama Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.normalize_messages_for_ollama`: "}]}
1256
1258
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.openwebui_rest_call Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.openwebui_rest_call`: Supported Method Parameters\n\nopenwebui_rest_call(\n\nhttp_method: 'optional HTTP method (defaults to GET)',\nrest_call: 'required rest call path relative to base_uri',\nparams: 'optional params passed in the URI or HTTP Headers',\nhttp_body: 'optional HTTP body sent in HTTP methods that support it e.g. POST',\ntimeout: 'optional timeout in seconds (defaults to 900)',\nspinner: 'optional - display spinner (defaults to false)'\n\n)\n"}]}
1257
1259
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.parse_tool_arguments_object Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.parse_tool_arguments_object`: "}]}
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pwn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.757
4
+ version: 0.5.758
5
5
  platform: ruby
6
6
  authors:
7
7
  - 0day Inc.
@@ -6754,6 +6754,7 @@ files:
6754
6754
  - spec/lib/pwn/ai/model_catalog_spec.rb
6755
6755
  - spec/lib/pwn/ai/model_lookup_spec.rb
6756
6756
  - spec/lib/pwn/ai/ollama_spec.rb
6757
+ - spec/lib/pwn/ai/ollama_system_message_spec.rb
6757
6758
  - spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb
6758
6759
  - spec/lib/pwn/ai/open_ai_spec.rb
6759
6760
  - spec/lib/pwn/ai/open_web_ui_spec.rb