pwn 0.5.666 → 0.5.668

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,715 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'base64'
5
+ require 'securerandom'
6
+ require 'tty-spinner'
7
+
8
+ module PWN
9
+ module AI
10
+ # Client for Open WebUI's REST API via PWN::Plugins::TransparentBrowser (:rest).
11
+ #
12
+ # Live Open WebUI routes (gateway base_uri, no trailing slash):
13
+ # GET /api/v1/models OpenAI-compat model list (:data)
14
+ # POST /api/v1/chat/completions OpenAI-compat chat (SSE when stream:true)
15
+ # POST /api/chat/completions alias of the above
16
+ # GET /ollama/api/tags proxied Ollama tags (:models)
17
+ # POST /ollama/api/chat proxied Ollama chat (NDJSON when stream:true)
18
+ # POST /ollama/api/embed proxied embeddings (see PWN::MemoryIndex)
19
+ #
20
+ # Bare /api/chat and bare /v1/* are NOT API routes on stock Open WebUI —
21
+ # they 405 or return the SPA HTML shell.
22
+ module OpenWebUI
23
+ private_class_method def self.real_config_value?(opts = {})
24
+ s = opts[:value].to_s.strip
25
+ return false if s.empty?
26
+ return false if s.match?(/\A(optional|required)\b/i)
27
+ return false if s.match?(/REDACTED/i)
28
+ return false if s.match?(/\A<{3}.*>{3}\z/)
29
+
30
+ true
31
+ end
32
+
33
+ # Supported Method Parameters::
34
+ # openwebui_rest_call(
35
+ # http_method: 'optional HTTP method (defaults to GET)',
36
+ # rest_call: 'required rest call path relative to base_uri',
37
+ # params: 'optional params passed in the URI or HTTP Headers',
38
+ # http_body: 'optional HTTP body sent in HTTP methods that support it e.g. POST',
39
+ # timeout: 'optional timeout in seconds (defaults to 900)',
40
+ # spinner: 'optional - display spinner (defaults to false)'
41
+ # )
42
+
43
+ private_class_method def self.openwebui_rest_call(opts = {})
44
+ engine = PWN::Env[:ai][:openwebui]
45
+ raise 'ERROR: Open WebUI engine hash not found in PWN::Env[:ai][:openwebui]. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
46
+
47
+ base_uri = engine[:base_uri].to_s.strip
48
+ raise 'ERROR: base_uri must be provided in PWN::Env[:ai][:openwebui][:base_uri]' unless real_config_value?(value: base_uri)
49
+
50
+ base_uri = base_uri.chomp('/')
51
+
52
+ token = real_config_value?(value: engine[:key]) ? engine[:key].to_s.strip : nil
53
+ if token.nil?
54
+ # JWT/API key is required for Open WebUI. Only solicit on a real TTY;
55
+ # never hang CI / agent tool loops waiting on mask_password.
56
+ interactive = $stdin.tty? && $stdout.tty? && ENV['PWN_NONINTERACTIVE'].to_s.empty?
57
+ raise 'ERROR: Open WebUI API key missing in PWN::Env[:ai][:openwebui][:key]. Set it via pwn-vault (Settings >> Account >> JWT Token).' unless interactive
58
+
59
+ token = PWN::Plugins::AuthenticationHelper.mask_password(prompt: 'Open WebUI (i.e. OpenAPI) Key')
60
+ raise 'ERROR: Open WebUI API key is required' if token.to_s.strip.empty?
61
+
62
+ engine[:key] = token
63
+ end
64
+
65
+ http_method = if opts[:http_method].nil?
66
+ :get
67
+ else
68
+ opts[:http_method].to_s.scrub.to_sym
69
+ end
70
+ rest_call = opts[:rest_call].to_s.scrub.sub(%r{\A/}, '')
71
+ params = opts[:params]
72
+
73
+ headers = {
74
+ content_type: 'application/json; charset=UTF-8',
75
+ authorization: "Bearer #{token}"
76
+ }
77
+
78
+ http_body = opts[:http_body]
79
+ http_body ||= {}
80
+
81
+ timeout = opts[:timeout]
82
+ timeout ||= 900
83
+
84
+ spinner = opts[:spinner] || false
85
+ stream = http_body.is_a?(Hash) && http_body[:stream] == true
86
+
87
+ browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
88
+ rest_client = browser_obj[:browser]::Request
89
+
90
+ if spinner
91
+ spin = TTY::Spinner.new(format: :dots)
92
+ spin.auto_spin
93
+ end
94
+
95
+ retry_count = 0
96
+ begin
97
+ case http_method
98
+ when :delete, :get
99
+ headers[:params] = params
100
+ response = rest_client.execute(
101
+ method: http_method,
102
+ url: "#{base_uri}/#{rest_call}",
103
+ headers: headers,
104
+ verify_ssl: false,
105
+ timeout: timeout
106
+ )
107
+
108
+ when :post
109
+ if http_body.key?(:multipart)
110
+ headers[:content_type] = 'multipart/form-data'
111
+
112
+ response = rest_client.execute(
113
+ method: http_method,
114
+ url: "#{base_uri}/#{rest_call}",
115
+ headers: headers,
116
+ payload: http_body,
117
+ verify_ssl: false,
118
+ timeout: timeout
119
+ )
120
+ elsif stream
121
+ # RestClient :block_response yields Net::HTTPResponse; we must
122
+ # drain the body ourselves. Open WebUI emits NDJSON (proxied
123
+ # /ollama/api/*) or SSE data: lines (OpenAI-compat /api/v1/*).
124
+ # Assemble into a single non-stream JSON body so callers keep
125
+ # JSON.parse + the same :message / :choices shape.
126
+ #
127
+ # IMPORTANT: do NOT RestClient::Response.create(..., request=nil).
128
+ # AbstractResponse#history calls request.redirection_history and
129
+ # raises NoMethodError.
130
+ #
131
+ # ABSOLUTE DEADLINE: Net::HTTP read_timeout only fires on *idle*
132
+ # gaps between chunks. A thinking model that dribbles tokens
133
+ # forever never idles out. Enforce a wall-clock deadline
134
+ # independent of chunk cadence.
135
+ assembled = nil
136
+ stream_err = nil
137
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
138
+ idle_timeout = [timeout.to_f, 120.0].min
139
+ rest_client.execute(
140
+ method: http_method,
141
+ url: "#{base_uri}/#{rest_call}",
142
+ headers: headers,
143
+ payload: http_body.to_json,
144
+ verify_ssl: false,
145
+ open_timeout: [timeout.to_f, 30.0].min,
146
+ read_timeout: idle_timeout,
147
+ block_response: lambda do |http_resp|
148
+ buf = +''
149
+ bytes = 0
150
+ http_resp.read_body do |chunk|
151
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
+ if now > deadline
153
+ stream_err = "ERROR: Open WebUI stream absolute timeout after #{timeout}s for #{rest_call} (received #{bytes} bytes - model likely stuck in unbounded thinking; lower num_ctx / set num_predict)"
154
+ raise stream_err
155
+ end
156
+ buf << chunk
157
+ bytes += chunk.bytesize
158
+ end
159
+ code = http_resp.code.to_i
160
+ unless code.between?(200, 299)
161
+ stream_err = "ERROR: Open WebUI HTTP #{code} for #{rest_call}: #{buf.to_s[0, 800]}"
162
+ return
163
+ end
164
+ if buf.to_s.strip.empty?
165
+ stream_err = "ERROR: Open WebUI empty stream body for #{rest_call} (HTTP #{code})"
166
+ return
167
+ end
168
+ # SPA HTML mis-route (wrong path → 200 HTML shell)
169
+ if buf.lstrip.start_with?('<!DOCTYPE', '<!doctype', '<html')
170
+ stream_err = "ERROR: Open WebUI returned HTML for #{rest_call} (wrong API path or unauthenticated SPA fallback)"
171
+ return
172
+ end
173
+ assembled = assemble_openwebui_stream(body: buf, rest_call: rest_call)
174
+ end
175
+ )
176
+ raise stream_err if stream_err
177
+ raise "ERROR: Open WebUI stream produced no assembled body for #{rest_call}" if assembled.nil?
178
+
179
+ response = assembled
180
+ else
181
+ response = rest_client.execute(
182
+ method: http_method,
183
+ url: "#{base_uri}/#{rest_call}",
184
+ headers: headers,
185
+ payload: http_body.to_json,
186
+ verify_ssl: false,
187
+ timeout: timeout
188
+ )
189
+ end
190
+
191
+ else
192
+ raise "Unsupported HTTP Method #{http_method} for #{self} Plugin"
193
+ end
194
+
195
+ # Non-stream path: stock Open WebUI returns the SPA HTML shell
196
+ # (200) for wrong routes like bare /api/chat or /v1/* — reject so
197
+ # callers do not JSON.parse markup into nil choices.
198
+ if response.respond_to?(:to_s)
199
+ body_s = response.to_s
200
+ raise "ERROR: Open WebUI returned HTML for #{rest_call} (wrong API path or unauthenticated SPA fallback)" if body_s.lstrip.start_with?('<!DOCTYPE', '<!doctype', '<html')
201
+ end
202
+
203
+ response
204
+ rescue RestClient::TooManyRequests => e
205
+ retry_after = e.response.headers[:retry_after]&.to_i ||= (0.5 * (retry_count + 1))
206
+ sleep(retry_after + rand(0.3..5.0))
207
+ retry_count += 1
208
+
209
+ retry
210
+ end
211
+ rescue RestClient::ExceptionWithResponse => e
212
+ # Never return nil here — chat_with_tools used to `return nil if response.nil?`
213
+ # which made the agent loop print "[pwn-ai] engine returned no message"
214
+ # with no actionable error. Raise so the caller / REPL surfaces the body.
215
+ body = begin
216
+ e.response.to_s[0, 800]
217
+ rescue StandardError
218
+ ''
219
+ end
220
+ raise "ERROR: Open WebUI #{e.message}: #{body}"
221
+ rescue StandardError => e
222
+ case e.message
223
+ when '400 Bad Request', '404 Resource Not Found'
224
+ raise "#{e.message}: #{e.respond_to?(:response) ? e.response : e.message}"
225
+ else
226
+ raise e
227
+ end
228
+ ensure
229
+ spin.stop if spinner
230
+ end
231
+
232
+ # Pull a user-visible answer out of a reasoning/"thinking" channel.
233
+ # Prefer text after common end markers; otherwise return the full
234
+ # thinking string so the agent never surfaces a blank final.
235
+ private_class_method def self.visible_from_thinking(opts = {})
236
+ thinking = opts[:thinking].to_s
237
+ return '' if thinking.strip.empty?
238
+
239
+ markers = [
240
+ %r{</think>}i,
241
+ %r{</thinking>}i,
242
+ /(?:\A|\n)\s*Final\s*Answer\s*:\s*/i,
243
+ /(?:\A|\n)\s*Answer\s*:\s*/i,
244
+ /Final\s*Answer\s*:\s*/i
245
+ ]
246
+ markers.each do |rx|
247
+ if (m = thinking.match(rx))
248
+ tail = thinking[(m.end(0))..].to_s.strip
249
+ return tail unless tail.empty?
250
+ end
251
+ end
252
+ thinking.strip
253
+ end
254
+
255
+ # Merge an Open WebUI streaming body (NDJSON proxied /ollama/api/chat OR
256
+ # OpenAI-compat SSE on /api/v1/chat/completions) into a single JSON string
257
+ # matching the non-stream response shape used by #chat / #chat_with_tools.
258
+ private_class_method def self.assemble_openwebui_stream(opts = {})
259
+ body = opts[:body].to_s
260
+ rest_call = opts[:rest_call].to_s
261
+
262
+ lines = body.each_line.map(&:strip).reject(&:empty?)
263
+ # Strip SSE "data: " prefix when present; drop the terminal [DONE].
264
+ payloads = lines.filter_map do |line|
265
+ line = line.sub(/\Adata:\s*/, '')
266
+ next if line.empty? || line == '[DONE]'
267
+
268
+ begin
269
+ JSON.parse(line, symbolize_names: true)
270
+ rescue JSON::ParserError
271
+ nil
272
+ end
273
+ end
274
+ if payloads.empty?
275
+ return {
276
+ message: {
277
+ role: 'assistant',
278
+ content: '',
279
+ error: 'empty_stream_payloads'
280
+ },
281
+ done: true,
282
+ done_reason: 'error'
283
+ }.to_json
284
+ end
285
+
286
+ openai_compat = rest_call.include?('/v1/') ||
287
+ rest_call.start_with?('v1/') ||
288
+ rest_call.include?('chat/completions') ||
289
+ payloads.any? { |p| p.key?(:choices) }
290
+
291
+ if openai_compat
292
+ assemble_openai_compat_stream(payloads: payloads)
293
+ else
294
+ assemble_native_chat_stream(payloads: payloads)
295
+ end
296
+ end
297
+
298
+ # Proxied Ollama /ollama/api/chat NDJSON → single-object JSON string.
299
+ private_class_method def self.assemble_native_chat_stream(opts = {})
300
+ payloads = opts[:payloads]
301
+ final = payloads.reverse.find { |p| p[:done] } || payloads.last
302
+ content = +''
303
+ thinking = +''
304
+ tool_calls = []
305
+ role = 'assistant'
306
+
307
+ payloads.each do |p|
308
+ msg = p[:message] || {}
309
+ role = msg[:role] if msg[:role]
310
+ content << msg[:content].to_s if msg[:content]
311
+ thinking << msg[:thinking].to_s if msg[:thinking]
312
+ thinking << msg[:reasoning_content].to_s if msg[:reasoning_content]
313
+ Array(msg[:tool_calls]).each do |tc|
314
+ tool_calls << tc unless tool_calls.any? { |existing| existing == tc }
315
+ end
316
+ end
317
+
318
+ content = visible_from_thinking(thinking: thinking) if content.empty? && !thinking.empty? && tool_calls.empty?
319
+
320
+ message = { role: role, content: content }
321
+ message[:thinking] = thinking unless thinking.empty?
322
+ message[:tool_calls] = tool_calls unless tool_calls.empty?
323
+
324
+ merged = {
325
+ model: final[:model],
326
+ created_at: final[:created_at],
327
+ message: message,
328
+ done: true,
329
+ done_reason: final[:done_reason],
330
+ total_duration: final[:total_duration],
331
+ load_duration: final[:load_duration],
332
+ prompt_eval_count: final[:prompt_eval_count],
333
+ prompt_eval_duration: final[:prompt_eval_duration],
334
+ eval_count: final[:eval_count],
335
+ eval_duration: final[:eval_duration]
336
+ }.compact
337
+
338
+ merged.to_json
339
+ end
340
+
341
+ # OpenAI-compat /api/v1/chat/completions SSE → single chat.completion JSON.
342
+ private_class_method def self.assemble_openai_compat_stream(opts = {})
343
+ payloads = opts[:payloads]
344
+ base = payloads.first || {}
345
+ content = +''
346
+ thinking = +''
347
+ role = 'assistant'
348
+ tool_calls_by_idx = {}
349
+ finish_reason = nil
350
+ usage = nil
351
+
352
+ payloads.each do |p|
353
+ usage = p[:usage] if p[:usage]
354
+ Array(p[:choices]).each do |ch|
355
+ finish_reason = ch[:finish_reason] if ch[:finish_reason]
356
+ delta = ch[:delta] || ch[:message] || {}
357
+ role = delta[:role] if delta[:role]
358
+ content << delta[:content].to_s if delta[:content]
359
+ thinking << delta[:reasoning_content].to_s if delta[:reasoning_content]
360
+ thinking << delta[:thinking].to_s if delta[:thinking]
361
+
362
+ Array(delta[:tool_calls]).each do |tc|
363
+ idx = tc[:index] || 0
364
+ slot = tool_calls_by_idx[idx] ||= {
365
+ id: nil,
366
+ type: 'function',
367
+ function: { name: +'', arguments: +'' }
368
+ }
369
+ slot[:id] = tc[:id] if tc[:id]
370
+ slot[:type] = tc[:type] if tc[:type]
371
+ fn = tc[:function] || {}
372
+ slot[:function][:name] << fn[:name].to_s if fn[:name]
373
+ slot[:function][:arguments] << fn[:arguments].to_s if fn[:arguments]
374
+ end
375
+ end
376
+ end
377
+
378
+ content = visible_from_thinking(thinking: thinking) if content.empty? && !thinking.empty? && tool_calls_by_idx.empty?
379
+
380
+ message = { role: role, content: content }
381
+ message[:thinking] = thinking unless thinking.empty?
382
+ unless tool_calls_by_idx.empty?
383
+ message[:tool_calls] = tool_calls_by_idx.keys.sort.map do |idx|
384
+ tool_calls_by_idx[idx]
385
+ end
386
+ end
387
+
388
+ merged = {
389
+ id: base[:id],
390
+ object: 'chat.completion',
391
+ created: base[:created],
392
+ model: base[:model],
393
+ choices: [{
394
+ index: 0,
395
+ message: message,
396
+ finish_reason: finish_reason || 'stop'
397
+ }]
398
+ }
399
+ merged[:usage] = usage if usage
400
+ merged.to_json
401
+ end
402
+
403
+ # Supported Method Parameters::
404
+ # response = PWN::AI::OpenWebUI.get_models
405
+
406
+ public_class_method def self.get_models
407
+ # Prefer Open WebUI's own OpenAI-compat catalog; fall back to the
408
+ # proxied Ollama tag list when needed.
409
+ raw = openwebui_rest_call(rest_call: 'api/v1/models')
410
+ parsed = JSON.parse(raw, symbolize_names: true)
411
+ return parsed[:data] if parsed[:data].is_a?(Array)
412
+ return parsed[:models] if parsed[:models].is_a?(Array)
413
+
414
+ raw = openwebui_rest_call(rest_call: 'ollama/api/tags')
415
+ JSON.parse(raw, symbolize_names: true)[:models]
416
+ rescue StandardError => e
417
+ raise e
418
+ end
419
+
420
+ # Coerce OpenAI-wire message history into Ollama-native shapes before
421
+ # POST /api/chat (or Open WebUI /ollama/api/chat):
422
+ # - function.arguments must be a Hash/Array object, not a JSON string
423
+ # (string args → HTTP 400 "can't find closing '}' symbol")
424
+ # - assistant content nil + tool_calls → "" (Open WebUI form validation)
425
+ private_class_method def self.parse_tool_arguments_object(opts = {})
426
+ raw = opts[:arguments]
427
+ case raw
428
+ when Hash, Array then raw
429
+ when nil then {}
430
+ when String
431
+ s = raw.strip
432
+ return {} if s.empty?
433
+
434
+ begin
435
+ parsed = JSON.parse(s, symbolize_names: true)
436
+ return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
437
+ rescue JSON::ParserError
438
+ nil
439
+ end
440
+ { value: s }
441
+ else
442
+ { value: raw.to_s }
443
+ end
444
+ end
445
+
446
+ private_class_method def self.normalize_messages_for_ollama(opts = {})
447
+ Array(opts[:messages]).filter_map do |m|
448
+ next unless m.is_a?(Hash)
449
+
450
+ role = (m[:role] || m['role']).to_s
451
+ out = { role: role }
452
+
453
+ tcs = m[:tool_calls] || m['tool_calls']
454
+ has_tcs = false
455
+ if tcs
456
+ wired = Array(tcs).filter_map do |tc|
457
+ next unless tc.is_a?(Hash)
458
+
459
+ fn = tc[:function] || tc['function'] || {}
460
+ name = fn[:name] || fn['name'] || tc[:name] || tc['name']
461
+ args = fn[:arguments] || fn['arguments'] || tc[:arguments] || tc['arguments']
462
+ {
463
+ id: (tc[:id] || tc['id'] || "call_#{SecureRandom.hex(4)}").to_s,
464
+ type: (tc[:type] || tc['type'] || 'function').to_s,
465
+ function: {
466
+ name: name.to_s,
467
+ arguments: parse_tool_arguments_object(arguments: args)
468
+ }
469
+ }
470
+ end
471
+ unless wired.empty?
472
+ out[:tool_calls] = wired
473
+ has_tcs = true
474
+ end
475
+ end
476
+
477
+ if m.key?(:content) || m.key?('content')
478
+ content = m.key?(:content) ? m[:content] : m['content']
479
+ out[:content] = case content
480
+ when nil then has_tcs ? '' : nil
481
+ when String then content
482
+ when Hash, Array then JSON.generate(content)
483
+ else content.to_s
484
+ end
485
+ elsif has_tcs
486
+ out[:content] = ''
487
+ end
488
+
489
+ name = m[:name] || m['name']
490
+ out[:name] = name.to_s if name && !name.to_s.empty?
491
+
492
+ tcid = m[:tool_call_id] || m['tool_call_id']
493
+ out[:tool_call_id] = tcid.to_s if tcid && !tcid.to_s.empty?
494
+
495
+ out
496
+ end
497
+ end
498
+
499
+ # Supported Method Parameters::
500
+ # response = PWN::AI::OpenWebUI.chat_with_tools(
501
+ # messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
502
+ # tools: 'optional - OpenAI tools array [{type:"function", function:{...}}]',
503
+ # tool_choice: 'optional - "auto" | "none" | {type:"function", function:{name:..}}',
504
+ # model: 'optional - overrides PWN::Env[:ai][:openwebui][:model]',
505
+ # temp: 'optional - temperature (defaults to PWN::Env[:ai][:openwebui][:temp] || 1)',
506
+ # timeout: 'optional - seconds (default 900)',
507
+ # spinner: 'optional - display spinner (default false)'
508
+ # )
509
+ #
510
+ # Hits Open WebUI's PROXIED Ollama POST /ollama/api/chat so
511
+ # options.num_ctx / num_predict / keep_alive take effect. Streaming is ON;
512
+ # openwebui_rest_call assembles NDJSON chunks back into a single response.
513
+ # Bare POST /api/chat is not an API route on stock Open WebUI (405).
514
+
515
+ public_class_method def self.chat_with_tools(opts = {})
516
+ engine = PWN::Env[:ai][:openwebui]
517
+ messages = normalize_messages_for_ollama(messages: opts[:messages])
518
+ raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
519
+
520
+ model = opts[:model] ||= engine[:model]
521
+ raise 'ERROR: Model is required. Call #get_models method for details' unless real_config_value?(value: model)
522
+
523
+ temp = opts[:temp].to_f
524
+ temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
525
+
526
+ tools_present = opts[:tools] && !opts[:tools].empty?
527
+ tool_temp = (engine[:tool_temp] || 0.1).to_f
528
+ num_ctx = (engine[:num_ctx] || 32_768).to_i
529
+ num_predict = (engine[:num_predict] || 4_096).to_i
530
+ keep_alive = engine[:keep_alive] || '30m'
531
+
532
+ http_body = {
533
+ model: model,
534
+ messages: messages,
535
+ stream: true,
536
+ keep_alive: keep_alive,
537
+ options: {
538
+ num_ctx: num_ctx,
539
+ num_predict: num_predict,
540
+ temperature: tools_present ? tool_temp : temp
541
+ }
542
+ }
543
+ if tools_present
544
+ http_body[:tools] = opts[:tools]
545
+ fmt = engine[:format]
546
+ http_body[:format] = fmt unless fmt.nil? || fmt.to_s.empty?
547
+ end
548
+ http_body[:tool_choice] = opts[:tool_choice] if opts[:tool_choice]
549
+
550
+ response = openwebui_rest_call(
551
+ http_method: :post,
552
+ rest_call: 'ollama/api/chat',
553
+ http_body: http_body,
554
+ timeout: opts[:timeout],
555
+ spinner: opts[:spinner]
556
+ )
557
+ raise 'ERROR: Open WebUI chat_with_tools received empty response from openwebui_rest_call' if response.nil? || (response.respond_to?(:empty?) && response.empty?)
558
+
559
+ json_resp = JSON.parse(response, symbolize_names: true)
560
+ msg = json_resp[:message] || json_resp.dig(:choices, 0, :message)
561
+ if msg.is_a?(Hash)
562
+ content = msg[:content].to_s
563
+ thinking = msg[:thinking].to_s
564
+ thinking = msg[:reasoning_content].to_s if thinking.empty?
565
+ tcalls = Array(msg[:tool_calls])
566
+ msg = msg.merge(content: visible_from_thinking(thinking: thinking)) if content.strip.empty? && !thinking.strip.empty? && tcalls.empty?
567
+ end
568
+ json_resp[:choices] = [{ message: msg }] if msg && !json_resp.key?(:choices)
569
+ json_resp[:assistant_message] = msg
570
+ raise "ERROR: Open WebUI response missing message/choices: #{json_resp.inspect[0, 400]}" if msg.nil?
571
+
572
+ json_resp
573
+ rescue StandardError => e
574
+ raise e
575
+ end
576
+
577
+ # Supported Method Parameters::
578
+ # response = PWN::AI::OpenWebUI.chat(
579
+ # request: 'required - message to Open WebUI'
580
+ # model: 'optional - model to use for text generation (defaults to PWN::Env[:ai][:openwebui][:model])',
581
+ # temp: 'optional - creative response float (deafults to PWN::Env[:ai][:openwebui][:temp])',
582
+ # system_role_content: 'optional - context to set up the model behavior for conversation (Default: PWN::Env[:ai][:openwebui][:system_role_content])',
583
+ # response_history: 'optional - pass response back in to have a conversation',
584
+ # speak_answer: 'optional speak answer using PWN::Plugins::Voice.text_to_speech (Default: nil)',
585
+ # timeout: 'optional timeout in seconds (defaults to 900)',
586
+ # spinner: 'optional - display spinner (defaults to false)'
587
+ # )
588
+
589
+ public_class_method def self.chat(opts = {})
590
+ engine = PWN::Env[:ai][:openwebui]
591
+ request = opts[:request]
592
+ max_prompt_length = engine[:max_prompt_length] ||= 1_000_000
593
+ request_trunc_idx = ((max_prompt_length - 1) / 3.36).floor
594
+ request = request[0..request_trunc_idx]
595
+
596
+ model = opts[:model] ||= engine[:model]
597
+ raise 'ERROR: Model is required. Call #get_models method for details' unless real_config_value?(value: model)
598
+
599
+ temp = opts[:temp].to_f ||= engine[:temp].to_f
600
+ temp = 1 if temp.zero?
601
+
602
+ # Open WebUI OpenAI-compat path (NOT bare v1/* — that hits the SPA).
603
+ rest_call = 'api/v1/chat/completions'
604
+
605
+ response_history = opts[:response_history]
606
+
607
+ max_tokens = response_history[:usage][:total_tokens] unless response_history.nil?
608
+
609
+ system_role_content = opts[:system_role_content] ||= engine[:system_role_content]
610
+
611
+ system_role = {
612
+ role: 'system',
613
+ content: system_role_content
614
+ }
615
+
616
+ user_role = {
617
+ role: 'user',
618
+ content: request
619
+ }
620
+
621
+ response_history ||= { choices: [system_role] }
622
+ choices_len = response_history[:choices].length
623
+
624
+ http_body = {
625
+ model: model,
626
+ messages: [system_role],
627
+ temperature: temp,
628
+ stream: true
629
+ }
630
+
631
+ if response_history[:choices].length > 1
632
+ response_history[:choices][1..].each do |message|
633
+ http_body[:messages].push(message)
634
+ end
635
+ end
636
+
637
+ http_body[:messages].push(user_role)
638
+
639
+ timeout = opts[:timeout]
640
+ spinner = opts[:spinner]
641
+
642
+ response = openwebui_rest_call(
643
+ http_method: :post,
644
+ rest_call: rest_call,
645
+ http_body: http_body,
646
+ timeout: timeout,
647
+ spinner: spinner
648
+ )
649
+
650
+ json_resp = JSON.parse(response, symbolize_names: true)
651
+ assistant_resp = json_resp[:choices].first[:message]
652
+ if assistant_resp.is_a?(Hash)
653
+ content = assistant_resp[:content].to_s
654
+ thinking = assistant_resp[:thinking].to_s
655
+ thinking = assistant_resp[:reasoning_content].to_s if thinking.empty?
656
+ assistant_resp = assistant_resp.merge(content: visible_from_thinking(thinking: thinking)) if content.strip.empty? && !thinking.strip.empty?
657
+ end
658
+ json_resp[:choices] = http_body[:messages]
659
+ json_resp[:choices].push(assistant_resp)
660
+
661
+ speak_answer = true if opts[:speak_answer]
662
+
663
+ if speak_answer
664
+ answer = assistant_resp[:content]
665
+ text_path = "/tmp/#{SecureRandom.hex}.pwn_voice"
666
+ File.write(text_path, answer)
667
+ PWN::Plugins::Voice.text_to_speech(text_path: text_path)
668
+ File.unlink(text_path)
669
+ end
670
+
671
+ json_resp
672
+ rescue StandardError => e
673
+ raise e
674
+ end
675
+
676
+ # Author(s):: 0day Inc. <support@0dayinc.com>
677
+
678
+ public_class_method def self.authors
679
+ "AUTHOR(S):
680
+ 0day Inc. <support@0dayinc.com>
681
+ "
682
+ end
683
+
684
+ # Display Usage for this Module
685
+
686
+ public_class_method def self.help
687
+ puts "USAGE:
688
+ models = #{self}.get_models
689
+
690
+ response = #{self}.chat(
691
+ request: 'required - message to Open WebUI',
692
+ model: 'optional - model to use for text generation (defaults to PWN::Env[:ai][:openwebui][:model])',
693
+ temp: 'optional - creative response float (defaults to PWN::Env[:ai][:openwebui][:temp])',
694
+ system_role_content: 'optional - context to set up the model behavior for conversation (Default: PWN::Env[:ai][:openwebui][:system_role_content])',
695
+ response_history: 'optional - pass response back in to have a conversation',
696
+ speak_answer: 'optional speak answer using PWN::Plugins::Voice.text_to_speech (Default: nil)',
697
+ timeout: 'optional - timeout in seconds (defaults to 900)',
698
+ spinner: 'optional - display spinner (defaults to false)'
699
+ )
700
+
701
+ response = #{self}.chat_with_tools(
702
+ messages: 'required - OpenAI-format messages array',
703
+ tools: 'optional - OpenAI tools array',
704
+ model: 'optional - overrides PWN::Env[:ai][:openwebui][:model]',
705
+ temp: 'optional - temperature',
706
+ timeout: 'optional - seconds (default 900)',
707
+ spinner: 'optional - display spinner (default false)'
708
+ )
709
+
710
+ #{self}.authors
711
+ "
712
+ end
713
+ end
714
+ end
715
+ end