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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +1 -0
- data/Gemfile +4 -4
- data/README.md +12 -3
- data/lib/pwn/ai/agent/dispatch.rb +161 -0
- data/lib/pwn/ai/agent/extrospection.rb +9 -5
- data/lib/pwn/ai/agent/learning.rb +2 -1
- data/lib/pwn/ai/agent/loop.rb +311 -18
- data/lib/pwn/ai/agent/prompt_builder.rb +12 -5
- data/lib/pwn/ai/agent/reflect.rb +1 -0
- data/lib/pwn/ai/agent/registry.rb +1 -1
- data/lib/pwn/ai/agent/result.rb +3 -3
- data/lib/pwn/ai/agent/swarm.rb +1 -1
- data/lib/pwn/ai/agent/task_summarizer.rb +1 -0
- data/lib/pwn/ai/agent/tools/memory.rb +18 -4
- data/lib/pwn/ai/grok.rb +3 -0
- data/lib/pwn/ai/ollama.rb +131 -78
- data/lib/pwn/ai/open_ai.rb +3 -0
- data/lib/pwn/ai/open_web_ui.rb +715 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/config.rb +70 -36
- data/lib/pwn/memory.rb +140 -11
- data/lib/pwn/memory_index.rb +65 -19
- data/lib/pwn/plugins/repl.rb +6 -4
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reinforced_feedback_loop_spec.rb +8 -6
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +78 -0
- data/spec/lib/pwn/ai/agent/extrospection_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +117 -0
- data/spec/lib/pwn/ai/grok_spec.rb +5 -0
- data/spec/lib/pwn/ai/ollama_spec.rb +36 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/ai/open_web_ui_spec.rb +302 -0
- data/spec/lib/pwn/config_spec.rb +70 -0
- data/spec/lib/pwn/memory_spec.rb +63 -0
- data/third_party/pwn_rdoc.jsonl +32 -5
- metadata +11 -9
|
@@ -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
|