pwn 0.5.756 → 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 +4 -4
- data/Gemfile +1 -1
- data/documentation/pwn-ai-Agent.md +2 -2
- data/etc/default_skills/pwn/ai/anthropic/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/gemini/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/grok/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/model_catalog/SKILL.md +52 -0
- data/etc/default_skills/pwn/ai/ollama/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/open_ai/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/open_web_ui/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +1 -1
- data/lib/pwn/ai/anthropic.rb +31 -0
- data/lib/pwn/ai/gemini.rb +32 -0
- data/lib/pwn/ai/grok.rb +31 -0
- data/lib/pwn/ai/model_catalog.rb +113 -0
- data/lib/pwn/ai/ollama.rb +113 -5
- data/lib/pwn/ai/open_ai.rb +33 -0
- data/lib/pwn/ai/open_web_ui.rb +117 -5
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/plugins/repl/ai_console.rb +58 -30
- data/lib/pwn/plugins/repl/ai_console_usage.rb +196 -10
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/grok_spec.rb +8 -0
- data/spec/lib/pwn/ai/model_catalog_spec.rb +26 -0
- data/spec/lib/pwn/ai/model_lookup_spec.rb +27 -0
- data/spec/lib/pwn/ai/ollama_spec.rb +2 -1
- data/spec/lib/pwn/ai/ollama_system_message_spec.rb +52 -0
- data/spec/lib/pwn/ai/open_web_ui_spec.rb +29 -1
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +46 -2
- data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +49 -0
- data/third_party/pwn_rdoc.jsonl +34 -0
- metadata +8 -3
data/lib/pwn/ai/open_web_ui.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'base64'
|
|
5
5
|
require 'securerandom'
|
|
6
|
+
require 'uri'
|
|
6
7
|
|
|
7
8
|
module PWN
|
|
8
9
|
module AI
|
|
@@ -433,11 +434,43 @@ module PWN
|
|
|
433
434
|
raise e
|
|
434
435
|
end
|
|
435
436
|
|
|
437
|
+
# Supported Method Parameters::
|
|
438
|
+
# model = PWN::AI::OpenWebUI.get_model(name: 'required - model id')
|
|
439
|
+
#
|
|
440
|
+
# Open WebUI mixes OpenAI-compat rows and proxied Ollama tags. A direct
|
|
441
|
+
# model route is tried first; otherwise the already-fetched catalog row
|
|
442
|
+
# is returned. Local tags without a price stay unpriced.
|
|
443
|
+
|
|
444
|
+
public_class_method def self.get_model(opts = {})
|
|
445
|
+
name = opts[:name].to_s.strip
|
|
446
|
+
raise 'ERROR: Model name is required' if name.empty?
|
|
447
|
+
|
|
448
|
+
hop = PWN::AI::ModelCatalog.lookup_opts(opts)
|
|
449
|
+
encoded = URI.encode_www_form_component(name)
|
|
450
|
+
row = PWN::AI::ModelCatalog.parse_row(raw: openwebui_rest_call(hop.merge(rest_call: "api/v1/models/#{encoded}")))
|
|
451
|
+
return row if PWN::AI::ModelCatalog.model_row?(row: row)
|
|
452
|
+
return nil if opts[:fallback] == false
|
|
453
|
+
|
|
454
|
+
listed = PWN::AI::ModelCatalog.parse_row(raw: openwebui_rest_call(hop.merge(rest_call: 'api/v1/models')))
|
|
455
|
+
found = PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? (listed[:data] || listed[:models]) : listed, name: name)
|
|
456
|
+
return found if found
|
|
457
|
+
|
|
458
|
+
tags = PWN::AI::ModelCatalog.parse_row(raw: openwebui_rest_call(hop.merge(rest_call: 'ollama/api/tags')))
|
|
459
|
+
PWN::AI::ModelCatalog.find_row(models: tags.is_a?(Hash) ? tags[:models] : tags, name: name)
|
|
460
|
+
rescue StandardError => e
|
|
461
|
+
raise e if name.empty?
|
|
462
|
+
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
|
|
436
466
|
# Coerce OpenAI-wire message history into Ollama-native shapes before
|
|
437
467
|
# POST /api/chat (or Open WebUI /ollama/api/chat):
|
|
438
468
|
# - function.arguments must be a Hash/Array object, not a JSON string
|
|
439
469
|
# (string args → HTTP 400 "can't find closing '}' symbol")
|
|
440
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.
|
|
441
474
|
private_class_method def self.parse_tool_arguments_object(opts = {})
|
|
442
475
|
raw = opts[:arguments]
|
|
443
476
|
case raw
|
|
@@ -460,7 +493,7 @@ module PWN
|
|
|
460
493
|
end
|
|
461
494
|
|
|
462
495
|
private_class_method def self.normalize_messages_for_ollama(opts = {})
|
|
463
|
-
Array(opts[:messages]).filter_map do |m|
|
|
496
|
+
rows = Array(opts[:messages]).filter_map do |m|
|
|
464
497
|
next unless m.is_a?(Hash)
|
|
465
498
|
|
|
466
499
|
role = (m[:role] || m['role']).to_s
|
|
@@ -510,6 +543,71 @@ module PWN
|
|
|
510
543
|
|
|
511
544
|
out
|
|
512
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
|
|
513
611
|
end
|
|
514
612
|
|
|
515
613
|
# Supported Method Parameters::
|
|
@@ -529,13 +627,16 @@ module PWN
|
|
|
529
627
|
# Bare POST /api/chat is not an API route on stock Open WebUI (405).
|
|
530
628
|
|
|
531
629
|
public_class_method def self.chat_with_tools(opts = {})
|
|
532
|
-
engine
|
|
533
|
-
messages = normalize_messages_for_ollama(messages: opts[:messages])
|
|
534
|
-
raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
|
|
535
|
-
|
|
630
|
+
engine = PWN::Env[:ai][:openwebui]
|
|
536
631
|
model = opts[:model] ||= engine[:model]
|
|
537
632
|
raise 'ERROR: Model is required. Call #get_models method for details' unless real_config_value?(value: model)
|
|
538
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
|
+
|
|
539
640
|
temp = opts[:temp].to_f
|
|
540
641
|
temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
|
|
541
642
|
|
|
@@ -652,6 +753,10 @@ module PWN
|
|
|
652
753
|
end
|
|
653
754
|
|
|
654
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
|
+
)
|
|
655
760
|
|
|
656
761
|
timeout = opts[:timeout]
|
|
657
762
|
spinner = opts[:spinner]
|
|
@@ -705,6 +810,13 @@ module PWN
|
|
|
705
810
|
# Run get models and return its result
|
|
706
811
|
#{self}.get_models
|
|
707
812
|
|
|
813
|
+
# Return one catalog row. OpenAI-compat and proxied Ollama tags use different routes.
|
|
814
|
+
#{self}.get_model(
|
|
815
|
+
name: 'required - model id',
|
|
816
|
+
timeout: 'optional - seconds (default 15)',
|
|
817
|
+
fallback: 'optional - false skips the catalog list when the direct route misses'
|
|
818
|
+
)
|
|
819
|
+
|
|
708
820
|
# Run chat with tools and return its result
|
|
709
821
|
#{self}.chat_with_tools(
|
|
710
822
|
messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
|
data/lib/pwn/ai.rb
CHANGED
|
@@ -13,6 +13,7 @@ module PWN
|
|
|
13
13
|
autoload :Grok, 'pwn/ai/grok'
|
|
14
14
|
autoload :HttpRetry, 'pwn/ai/http_retry'
|
|
15
15
|
autoload :MCP, 'pwn/ai/mcp'
|
|
16
|
+
autoload :ModelCatalog, 'pwn/ai/model_catalog'
|
|
16
17
|
autoload :Ollama, 'pwn/ai/ollama'
|
|
17
18
|
autoload :OpenAI, 'pwn/ai/open_ai'
|
|
18
19
|
autoload :OpenWebUI, 'pwn/ai/open_web_ui'
|
|
@@ -686,7 +686,7 @@ module PWN
|
|
|
686
686
|
|
|
687
687
|
return clear_view if key == "\u000c"
|
|
688
688
|
|
|
689
|
-
if
|
|
689
|
+
if ["\u0007", "\u0013"].include?(key)
|
|
690
690
|
@workspace ? @workspace = nil : open_swarm
|
|
691
691
|
return
|
|
692
692
|
end
|
|
@@ -709,7 +709,7 @@ module PWN
|
|
|
709
709
|
return
|
|
710
710
|
end
|
|
711
711
|
|
|
712
|
-
if
|
|
712
|
+
if ["\u0018", "\u0014"].include?(key)
|
|
713
713
|
@menu = nil
|
|
714
714
|
@focus = @focus == :session ? :mission : :session
|
|
715
715
|
return
|
|
@@ -757,9 +757,18 @@ module PWN
|
|
|
757
757
|
end
|
|
758
758
|
when :page_up then @scroll = [(@scroll || (@total_rows.to_i - @page_size.to_i)) - @page_size.to_i, 0].max
|
|
759
759
|
when :page_down
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
@
|
|
760
|
+
bottom = [@total_rows.to_i - @page_size.to_i, 0].max
|
|
761
|
+
# nil is the live end, not row 0. Treating it as 0 jumps upward, then later presses walk back down.
|
|
762
|
+
if @scroll.nil? || @scroll >= bottom
|
|
763
|
+
@scroll = nil
|
|
764
|
+
@unseen = 0
|
|
765
|
+
else
|
|
766
|
+
@scroll = [@scroll + @page_size.to_i, bottom].min
|
|
767
|
+
if @scroll >= bottom
|
|
768
|
+
@scroll = nil
|
|
769
|
+
@unseen = 0
|
|
770
|
+
end
|
|
771
|
+
end
|
|
763
772
|
when "\t" then accept_completion
|
|
764
773
|
when :home, :end
|
|
765
774
|
if @focus == :session
|
|
@@ -1247,11 +1256,34 @@ module PWN
|
|
|
1247
1256
|
end
|
|
1248
1257
|
end
|
|
1249
1258
|
|
|
1259
|
+
def header_content_limit
|
|
1260
|
+
compose = @compose_height || 5
|
|
1261
|
+
footer = @footer_height || 1
|
|
1262
|
+
[@height.to_i - compose - footer - 6, 1].max
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
def footer_text
|
|
1266
|
+
text = '/ menu · ^C=cancel · ^D=back · ^L=clear · ^T=toggle pane · ^S=swarm · ^R=search ^O=operations · ↑↓ history · Shift+Enter=newline · Enter=send'
|
|
1267
|
+
@focus == :session ? text.sub('↑↓ history', '↑↓ scroll · HOME · PGUP · PGDN · END') : text
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
def footer_lines
|
|
1271
|
+
wrap(footer_text, [@width.to_i - 2, 1].max)
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
def layout_chrome
|
|
1275
|
+
footer = footer_lines
|
|
1276
|
+
compose = 5
|
|
1277
|
+
footer_height = footer.length
|
|
1278
|
+
compose -= 1 while @height.to_i - 4 - compose - footer_height < 3 && compose > 3
|
|
1279
|
+
[footer, compose, footer_height]
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1250
1282
|
def draw_header(engine, model)
|
|
1251
1283
|
header_layout(engine, model)
|
|
1252
1284
|
content = header_lines(engine, model, @header_text_width)
|
|
1253
1285
|
# Reserve only the physical minimum: timeline (3), composer (6), footer (1).
|
|
1254
|
-
visible = @header_spans.first([
|
|
1286
|
+
visible = @header_spans.first([header_content_limit, 1].max)
|
|
1255
1287
|
overflow = visible.length < content.length ? ' · ^O full settings' : ''
|
|
1256
1288
|
height = @header_pane_height || (visible.length + 3)
|
|
1257
1289
|
left = @header_pane_height || 0
|
|
@@ -1303,11 +1335,14 @@ module PWN
|
|
|
1303
1335
|
end
|
|
1304
1336
|
engine = PWN::Env.dig(:ai, :active).to_s
|
|
1305
1337
|
model = REPL.pwn_ai_engine_model(engine: engine)
|
|
1338
|
+
footer, compose_height, footer_height = layout_chrome
|
|
1339
|
+
@compose_height = compose_height
|
|
1340
|
+
@footer_height = footer_height
|
|
1306
1341
|
header_height = draw_header(engine, model)
|
|
1307
1342
|
sidebar = @width >= 100 ? 27 : 0
|
|
1308
1343
|
pane_width = @width - sidebar - 2
|
|
1309
|
-
|
|
1310
|
-
timeline_height =
|
|
1344
|
+
timeline_height = @height - compose_height - footer_height - header_height
|
|
1345
|
+
timeline_height = 3 if timeline_height < 3
|
|
1311
1346
|
@page_size = timeline_height - 2
|
|
1312
1347
|
rows = timeline_rows(pane_width - 6)
|
|
1313
1348
|
@total_rows = rows.length
|
|
@@ -1320,19 +1355,13 @@ module PWN
|
|
|
1320
1355
|
start = @scroll || [rows.length - @page_size, 0].max
|
|
1321
1356
|
rows.slice(start, @page_size).to_a.each_with_index { |(color, row), index| put(header_height + 1 + index, 2, row, color) }
|
|
1322
1357
|
draw_sidebar(header_height, pane_width + 1, timeline_height) if sidebar.positive?
|
|
1323
|
-
compose_y = @height - compose_height -
|
|
1358
|
+
compose_y = @height - compose_height - footer_height
|
|
1324
1359
|
mission_title = @focus == :session ? 'MISSION CONTROL' : 'MISSION CONTROL · active'
|
|
1325
1360
|
box(compose_y, 0, compose_height, @width - 1, mission_title)
|
|
1326
1361
|
mark_active(compose_y, 0, @width - 1, mission_title) if @focus != :session
|
|
1327
|
-
draw_composer(compose_y)
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
elsif @focus == :session
|
|
1331
|
-
'↑↓/Home/End scroll · ^X mission'
|
|
1332
|
-
else
|
|
1333
|
-
'↑↓ history · ^X session'
|
|
1334
|
-
end
|
|
1335
|
-
put(@height - 1, 1, "^C cancel ^D back · #{hint} · ^G swarm ^L clear ^R search ^O status · Enter send", tone(:footer))
|
|
1362
|
+
draw_composer(compose_y, height: compose_height)
|
|
1363
|
+
footer_top = @height - footer_height
|
|
1364
|
+
footer.first(footer_height).each_with_index { |line, index| put(footer_top + index, 1, line, tone(:footer)) }
|
|
1336
1365
|
draw_menu if @menu
|
|
1337
1366
|
draw_model_prompt if @model_prompt
|
|
1338
1367
|
draw_details(engine, model) if @details
|
|
@@ -1346,30 +1375,29 @@ module PWN
|
|
|
1346
1375
|
@screen.refresh
|
|
1347
1376
|
end
|
|
1348
1377
|
|
|
1349
|
-
def draw_composer(top)
|
|
1350
|
-
return draw_search(top) if @editor.search
|
|
1378
|
+
def draw_composer(top, height: 5)
|
|
1379
|
+
return draw_search(top, height: height) if @editor.search
|
|
1351
1380
|
|
|
1352
1381
|
chars = @editor.text.scan(/\X/)
|
|
1353
1382
|
before = chars.take(@editor.cursor).join
|
|
1354
1383
|
rows = wrap(@editor.text, @width - 8, words: false)
|
|
1355
1384
|
prefix = wrap(before, @width - 8, words: false)
|
|
1356
1385
|
cursor_row = prefix.length - 1
|
|
1357
|
-
|
|
1386
|
+
interior = [height - 2, 1].max
|
|
1387
|
+
first = [cursor_row - (interior - 1), 0].max
|
|
1358
1388
|
put(top + 1, 2, '›', tone(:prompt)) if first.zero?
|
|
1359
|
-
rows.slice(first,
|
|
1360
|
-
hint = @menu ? 'Tab accept · ↑↓/^P/^N select · Esc close' : '^X pane · ^G swarm · Shift+Enter newline · / commands · ^O status'
|
|
1361
|
-
put(top + 4, 4, fit(hint, @width - 7), tone(:footer))
|
|
1389
|
+
rows.slice(first, interior).to_a.each_with_index { |row, index| put(top + 1 + index, 4, row, tone(:composer)) }
|
|
1362
1390
|
@cursor_position = [top + 1 + cursor_row - first, [4 + width(prefix.last), @width - 3].min]
|
|
1363
1391
|
end
|
|
1364
1392
|
|
|
1365
|
-
def draw_search(top)
|
|
1393
|
+
def draw_search(top, height: 5)
|
|
1366
1394
|
search = @editor.search
|
|
1367
1395
|
query = "reverse search: #{search[:query]}"
|
|
1368
1396
|
query_rows = wrap(query, @width - 8, words: false)
|
|
1369
1397
|
put(top + 1, 4, query_rows.last, tone(:category))
|
|
1370
1398
|
match = search[:match] || '(no match)'
|
|
1371
|
-
|
|
1372
|
-
|
|
1399
|
+
interior = [height - 2, 1].max
|
|
1400
|
+
wrap(match, @width - 8).first([interior - 1, 1].max).each_with_index { |row, index| put(top + 2 + index, 4, row, tone(:composer)) }
|
|
1373
1401
|
@cursor_position = [top + 1, [4 + width(query_rows.last), @width - 3].min]
|
|
1374
1402
|
end
|
|
1375
1403
|
|
|
@@ -1566,9 +1594,9 @@ module PWN
|
|
|
1566
1594
|
cursor = [bottom + 2, [3 + width(chars[first...editor.cursor].join), @width - 3].min]
|
|
1567
1595
|
else
|
|
1568
1596
|
swarm_put(bottom + 1, "DRAFT: #{@editor.text}", :composer)
|
|
1569
|
-
swarm_put(bottom + 2, 'Esc/^G return to unchanged mission draft', :footer)
|
|
1597
|
+
swarm_put(bottom + 2, 'Esc/^S/^G return to unchanged mission draft', :footer)
|
|
1570
1598
|
end
|
|
1571
|
-
put(@height - 1, 1, '^D back · ^G close · actions require Enter', tone(:footer))
|
|
1599
|
+
put(@height - 1, 1, '^D back · ^S/^G close · actions require Enter', tone(:footer))
|
|
1572
1600
|
@screen.setpos(*(cursor || [0, 2]))
|
|
1573
1601
|
end
|
|
1574
1602
|
|
|
@@ -1623,7 +1651,7 @@ module PWN
|
|
|
1623
1651
|
public_class_method def self.help
|
|
1624
1652
|
puts "USAGE:
|
|
1625
1653
|
# Ctrl+L clears only the session pane, including while busy.
|
|
1626
|
-
# Ctrl+
|
|
1654
|
+
# Ctrl+T toggles the active pane between SESSION and MISSION CONTROL. Ctrl+S opens swarm.
|
|
1627
1655
|
# Up/Down select in menus, recall ~/.pwn/pwn_history in the mission pane, and scroll the session pane when it is active.
|
|
1628
1656
|
# Search: Enter accepts without sending; Esc restores the draft.
|
|
1629
1657
|
# Launch the single-owner curses console; non-terminals return unavailable.
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module PWN
|
|
4
4
|
module Plugins
|
|
5
5
|
module REPL
|
|
6
|
-
# Provider-reported token totals
|
|
6
|
+
# Provider-reported token totals. USD comes from the active model's published
|
|
7
|
+
# prices, then from explicitly configured rates. Missing prices stay unavailable.
|
|
7
8
|
module AIConsoleUsage
|
|
8
9
|
# Accumulate one observer event per provider completion.
|
|
9
10
|
class Tracker
|
|
@@ -18,11 +19,15 @@ module PWN
|
|
|
18
19
|
@priced = 0.0
|
|
19
20
|
@priced_calls = 0
|
|
20
21
|
@unpriced_calls = 0
|
|
22
|
+
@catalog_priced = 0
|
|
23
|
+
@configured_priced = 0
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def record(opts = {})
|
|
24
27
|
usage = AIConsoleUsage.normalize(opts)
|
|
25
|
-
|
|
28
|
+
rates = opts[:rates] || AIConsoleUsage.send(:rates_for, engine: opts[:engine], model: opts[:model])
|
|
29
|
+
price = AIConsoleUsage.estimate(usage: usage, rates: rates)
|
|
30
|
+
source = rates.is_a?(Hash) ? rates[:source].to_s : ''
|
|
26
31
|
@lock.synchronize do
|
|
27
32
|
@input += usage[:input_tokens]
|
|
28
33
|
@output += usage[:output_tokens]
|
|
@@ -33,6 +38,11 @@ module PWN
|
|
|
33
38
|
if price
|
|
34
39
|
@priced += price
|
|
35
40
|
@priced_calls += 1
|
|
41
|
+
if source == 'provider'
|
|
42
|
+
@catalog_priced += 1
|
|
43
|
+
else
|
|
44
|
+
@configured_priced += 1
|
|
45
|
+
end
|
|
36
46
|
else
|
|
37
47
|
@unpriced_calls += 1
|
|
38
48
|
end
|
|
@@ -44,10 +54,14 @@ module PWN
|
|
|
44
54
|
@lock.synchronize do
|
|
45
55
|
status = if @calls.zero?
|
|
46
56
|
'no provider usage yet'
|
|
57
|
+
elsif @unpriced_calls.zero? && @catalog_priced.positive? && @configured_priced.zero?
|
|
58
|
+
'estimated from provider model pricing'
|
|
59
|
+
elsif @unpriced_calls.zero? && @catalog_priced.positive?
|
|
60
|
+
'estimated from configured USD rates and provider model pricing'
|
|
47
61
|
elsif @unpriced_calls.zero?
|
|
48
62
|
'estimated from configured USD rates'
|
|
49
63
|
elsif @priced_calls.zero?
|
|
50
|
-
'unavailable: no configured USD rate'
|
|
64
|
+
'unavailable: no provider or configured USD rate'
|
|
51
65
|
else
|
|
52
66
|
"partial: #{@unpriced_calls} call(s) have no configured rate"
|
|
53
67
|
end
|
|
@@ -66,8 +80,8 @@ module PWN
|
|
|
66
80
|
public_class_method def self.normalize(opts = {})
|
|
67
81
|
raw = opts[:usage].is_a?(Hash) ? opts[:usage] : opts
|
|
68
82
|
details = raw[:input_tokens_details] || raw[:prompt_tokens_details] || {}
|
|
69
|
-
cached = integer(value: details[:cached_tokens] || details[:cache_read_input_tokens] || raw[:cache_read_input_tokens])
|
|
70
|
-
written = integer(value: details[:cache_creation_input_tokens] || raw[:cache_creation_input_tokens])
|
|
83
|
+
cached = integer(value: details[:cached_tokens] || details[:cache_read_input_tokens] || raw[:cache_read_input_tokens] || raw[:cached_tokens])
|
|
84
|
+
written = integer(value: details[:cache_creation_input_tokens] || raw[:cache_creation_input_tokens] || raw[:cache_write_tokens])
|
|
71
85
|
input = integer(value: raw[:input_tokens] || raw[:prompt_tokens])
|
|
72
86
|
output = integer(value: raw[:output_tokens] || raw[:completion_tokens])
|
|
73
87
|
reasoning = integer(value: (raw[:output_tokens_details] || {})[:reasoning_tokens] || raw[:reasoning_tokens])
|
|
@@ -79,10 +93,12 @@ module PWN
|
|
|
79
93
|
|
|
80
94
|
# Price only when every used component has an explicit finite USD rate.
|
|
81
95
|
public_class_method def self.estimate(opts = {})
|
|
82
|
-
|
|
96
|
+
raw = opts[:usage].is_a?(Hash) ? opts[:usage] : opts
|
|
97
|
+
usage = normalize(usage: raw)
|
|
83
98
|
rates = opts[:rates] || rates_for(engine: opts[:engine], model: opts[:model])
|
|
84
99
|
return nil unless rates.is_a?(Hash)
|
|
85
100
|
|
|
101
|
+
rates = apply_long_context(rates: rates, usage: usage)
|
|
86
102
|
components = [
|
|
87
103
|
[usage[:input_tokens] - usage[:cached_tokens] - usage[:cache_write_tokens], rates[:input_per_million]],
|
|
88
104
|
[usage[:cached_tokens], rates[:cache_read_per_million] || rates[:input_per_million]],
|
|
@@ -106,12 +122,13 @@ module PWN
|
|
|
106
122
|
usage: 'required - provider usage hash'
|
|
107
123
|
)
|
|
108
124
|
|
|
109
|
-
# Estimate USD
|
|
125
|
+
# Estimate USD from the active model's published prices, then configured rates.
|
|
126
|
+
# nil means unavailable, never a fabricated zero.
|
|
110
127
|
#{self}.estimate(
|
|
111
128
|
usage: 'required - normalized or provider usage hash',
|
|
112
129
|
rates: 'optional - input_per_million, output_per_million, cache_read_per_million, cache_write_per_million',
|
|
113
|
-
engine: 'optional - provider
|
|
114
|
-
model: 'optional - model
|
|
130
|
+
engine: 'optional - provider whose get_model record publishes prices',
|
|
131
|
+
model: 'optional - model id passed to that provider get_model'
|
|
115
132
|
)
|
|
116
133
|
|
|
117
134
|
# Return module authors.
|
|
@@ -129,15 +146,184 @@ module PWN
|
|
|
129
146
|
end
|
|
130
147
|
|
|
131
148
|
private_class_method def self.rates_for(opts = {})
|
|
149
|
+
catalog = catalog_rates(opts)
|
|
150
|
+
return catalog.merge(source: 'provider') if catalog
|
|
151
|
+
|
|
152
|
+
configured = configured_rates(opts)
|
|
153
|
+
configured&.merge(source: 'configured')
|
|
154
|
+
rescue StandardError
|
|
155
|
+
configured_rates(opts)&.merge(source: 'configured')
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private_class_method def self.configured_rates(opts = {})
|
|
132
159
|
engine = opts[:engine].to_s.to_sym
|
|
133
160
|
model = opts[:model].to_s
|
|
134
161
|
pricing = PWN::Env.dig(:ai, engine, :pricing) if defined?(PWN::Env)
|
|
135
162
|
return nil unless pricing.is_a?(Hash)
|
|
136
163
|
|
|
137
|
-
pricing[model.to_sym] || pricing[model]
|
|
164
|
+
found = pricing[model.to_sym] || pricing[model]
|
|
165
|
+
found.is_a?(Hash) ? found : nil
|
|
166
|
+
rescue StandardError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private_class_method def self.catalog_rates(opts = {})
|
|
171
|
+
rates = extract_rates(row: model_record(opts))
|
|
172
|
+
return nil unless rates.is_a?(Hash)
|
|
173
|
+
return nil unless rate?(value: rates[:input_per_million]) && rate?(value: rates[:output_per_million])
|
|
174
|
+
return nil unless rates[:input_per_million].positive? && rates[:output_per_million].positive?
|
|
175
|
+
|
|
176
|
+
rates
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private_class_method def self.model_record(opts = {})
|
|
180
|
+
engine = opts[:engine].to_s
|
|
181
|
+
model = opts[:model].to_s
|
|
182
|
+
return nil if engine.empty? || model.empty?
|
|
183
|
+
|
|
184
|
+
key = "#{engine}\0#{model}"
|
|
185
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
186
|
+
hit = catalog_lock.synchronize { catalog_cache[key] }
|
|
187
|
+
return hit[:row] if hit && hit[:until] > now
|
|
188
|
+
|
|
189
|
+
klass = provider_class(engine: engine)
|
|
190
|
+
row = klass.get_model(name: model, timeout: 8, fallback: false, non_interactive: true) if klass.respond_to?(:get_model)
|
|
191
|
+
catalog_lock.synchronize do
|
|
192
|
+
catalog_cache[key] = { until: now + (row.is_a?(Hash) ? 300 : 30), row: row }
|
|
193
|
+
end
|
|
194
|
+
row
|
|
138
195
|
rescue StandardError
|
|
139
196
|
nil
|
|
140
197
|
end
|
|
198
|
+
|
|
199
|
+
private_class_method def self.provider_class(opts = {})
|
|
200
|
+
name = {
|
|
201
|
+
'anthropic' => :Anthropic, 'gemini' => :Gemini, 'grok' => :Grok,
|
|
202
|
+
'ollama' => :Ollama, 'openai' => :OpenAI, 'openwebui' => :OpenWebUI
|
|
203
|
+
}[opts[:engine].to_s.downcase]
|
|
204
|
+
return nil if name.nil? || !defined?(PWN::AI) || !PWN::AI.const_defined?(name)
|
|
205
|
+
|
|
206
|
+
PWN::AI.const_get(name)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private_class_method def self.extract_rates(opts = {})
|
|
210
|
+
row = opts[:row]
|
|
211
|
+
return nil unless row.is_a?(Hash)
|
|
212
|
+
|
|
213
|
+
xai = xai_rates(row: row)
|
|
214
|
+
return xai if xai
|
|
215
|
+
|
|
216
|
+
hashes = pricing_hashes(row: row)
|
|
217
|
+
input = pick_rate(hashes: hashes, keys: %i[input_per_million input_per_1m input_per_mtok input prompt prompt_price input_cost_per_token input_token_cost])
|
|
218
|
+
output = pick_rate(hashes: hashes, keys: %i[output_per_million output_per_1m output_per_mtok output completion completion_price output_cost_per_token output_token_cost])
|
|
219
|
+
return nil unless input && output
|
|
220
|
+
|
|
221
|
+
{
|
|
222
|
+
input_per_million: input,
|
|
223
|
+
output_per_million: output,
|
|
224
|
+
cache_read_per_million: pick_rate(hashes: hashes, keys: %i[cache_read_per_million cached_input cached_input_per_million cache_read cached_prompt input_cache_read]),
|
|
225
|
+
cache_write_per_million: pick_rate(hashes: hashes, keys: %i[cache_write_per_million cache_write cache_creation cache_creation_input_tokens])
|
|
226
|
+
}.compact
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# xAI publishes integer USD cents per 100 million tokens. Zero is unpublished, not free.
|
|
230
|
+
private_class_method def self.xai_rates(opts = {})
|
|
231
|
+
row = opts[:row]
|
|
232
|
+
return nil unless row.key?(:prompt_text_token_price) || row.key?('prompt_text_token_price')
|
|
233
|
+
|
|
234
|
+
input = xai_usd(value: row[:prompt_text_token_price] || row['prompt_text_token_price'])
|
|
235
|
+
output = xai_usd(value: row[:completion_text_token_price] || row['completion_text_token_price'])
|
|
236
|
+
return nil unless input && output
|
|
237
|
+
|
|
238
|
+
rates = {
|
|
239
|
+
input_per_million: input,
|
|
240
|
+
output_per_million: output,
|
|
241
|
+
cache_read_per_million: xai_usd(value: row[:cached_prompt_text_token_price] || row['cached_prompt_text_token_price'])
|
|
242
|
+
}
|
|
243
|
+
threshold = numeric_value(value: row[:long_context_threshold] || row[:prompt_token_price_threshold])
|
|
244
|
+
if threshold&.positive?
|
|
245
|
+
rates[:long_context_tokens] = threshold
|
|
246
|
+
rates[:long_input_per_million] = xai_usd(value: row[:prompt_text_token_price_long_context])
|
|
247
|
+
rates[:long_output_per_million] = xai_usd(value: row[:completion_text_token_price_long_context])
|
|
248
|
+
rates[:long_cache_read_per_million] = xai_usd(value: row[:cached_prompt_text_token_price_long_context])
|
|
249
|
+
end
|
|
250
|
+
rates.compact
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private_class_method def self.xai_usd(opts = {})
|
|
254
|
+
number = numeric_value(value: opts[:value])
|
|
255
|
+
return nil unless number&.finite? && number.positive?
|
|
256
|
+
|
|
257
|
+
number / 10_000.0
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private_class_method def self.apply_long_context(opts = {})
|
|
261
|
+
rates = opts[:rates]
|
|
262
|
+
threshold = rates[:long_context_tokens]
|
|
263
|
+
return rates unless threshold.is_a?(Numeric) && threshold.positive?
|
|
264
|
+
return rates if opts[:usage][:input_tokens].to_i < threshold
|
|
265
|
+
|
|
266
|
+
swapped = rates.dup
|
|
267
|
+
swapped[:input_per_million] = rates[:long_input_per_million] if rate?(value: rates[:long_input_per_million])
|
|
268
|
+
swapped[:output_per_million] = rates[:long_output_per_million] if rate?(value: rates[:long_output_per_million])
|
|
269
|
+
swapped[:cache_read_per_million] = rates[:long_cache_read_per_million] if rate?(value: rates[:long_cache_read_per_million])
|
|
270
|
+
swapped
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private_class_method def self.pricing_hashes(opts = {})
|
|
274
|
+
row = opts[:row]
|
|
275
|
+
nested = [
|
|
276
|
+
row, row[:pricing], row['pricing'], row[:price], row[:costs],
|
|
277
|
+
row.dig(:info, :pricing), row.dig(:info, :meta, :pricing), row.dig(:info, 'meta', 'pricing'),
|
|
278
|
+
row.dig(:metadata, :pricing), row.dig(:details, :pricing)
|
|
279
|
+
]
|
|
280
|
+
nested.grep(Hash)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
private_class_method def self.pick_rate(opts = {})
|
|
284
|
+
opts[:hashes].each do |hash|
|
|
285
|
+
opts[:keys].each do |key|
|
|
286
|
+
value = hash[key] || hash[key.to_s]
|
|
287
|
+
rate = money_per_million(value: value, key: key)
|
|
288
|
+
return rate if rate
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private_class_method def self.money_per_million(opts = {})
|
|
295
|
+
value = opts[:value]
|
|
296
|
+
if value.is_a?(Hash)
|
|
297
|
+
unit = (value[:unit] || value['unit'] || value[:per] || value['per']).to_s
|
|
298
|
+
value = value[:usd] || value['usd'] || value[:amount] || value['amount'] || value[:price] || value['price']
|
|
299
|
+
opts = opts.merge(value: value, key: unit.empty? ? opts[:key] : unit)
|
|
300
|
+
end
|
|
301
|
+
number = numeric_value(value: opts[:value])
|
|
302
|
+
return nil unless number&.finite? && number.positive?
|
|
303
|
+
|
|
304
|
+
key = opts[:key].to_s
|
|
305
|
+
per_million = key.match?(/per_million|per_1m|per_mtok/)
|
|
306
|
+
per_token = key.match?(/per_token|token_cost|cost_per_token/) || (!per_million && number < 0.001)
|
|
307
|
+
per_token ? number * 1_000_000 : number
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
private_class_method def self.numeric_value(opts = {})
|
|
311
|
+
value = opts[:value]
|
|
312
|
+
return value if value.is_a?(Numeric)
|
|
313
|
+
return nil unless value.is_a?(String) && value.match?(/\A-?\d+(\.\d+)?\z/)
|
|
314
|
+
|
|
315
|
+
Float(value)
|
|
316
|
+
rescue StandardError
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private_class_method def self.catalog_lock
|
|
321
|
+
@catalog_lock ||= Mutex.new
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
private_class_method def self.catalog_cache
|
|
325
|
+
@catalog_cache ||= {}
|
|
326
|
+
end
|
|
141
327
|
end
|
|
142
328
|
end
|
|
143
329
|
end
|
data/lib/pwn/version.rb
CHANGED
|
@@ -13,6 +13,14 @@ describe PWN::AI::Grok do
|
|
|
13
13
|
expect(help_response).to respond_to :help
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
it 'loads one priced model without listing the catalog' do
|
|
17
|
+
allow(described_class).to receive(:grok_rest_call).and_return('{"id":"grok-4.7","prompt_text_token_price":20000,"completion_text_token_price":60000}')
|
|
18
|
+
row = described_class.get_model(name: 'grok-4.7')
|
|
19
|
+
expect(row[:id]).to eq('grok-4.7')
|
|
20
|
+
expect(described_class).to have_received(:grok_rest_call).once
|
|
21
|
+
expect { described_class.get_model(name: ' ') }.to raise_error(RuntimeError, /Model name is required/)
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
it 'chat_with_tools sanitizes messages via Loop.openai_wire_messages' do
|
|
17
25
|
src = File.read(described_class.method(:chat_with_tools).source_location.first)
|
|
18
26
|
expect(src).to match(/openai_wire_messages/)
|