pwn 0.5.755 → 0.5.757
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 +54 -10
- 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 +33 -0
- data/lib/pwn/ai/open_ai.rb +33 -0
- data/lib/pwn/ai/open_web_ui.rb +37 -0
- 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/anthropic_spec.rb +27 -0
- 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/plugins/binary_parser_spec.rb +11 -2
- 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 +32 -0
- metadata +7 -3
|
@@ -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
|
@@ -106,4 +106,31 @@ describe PWN::AI::Anthropic do
|
|
|
106
106
|
expect(flat[:properties].keys.map(&:to_s)).to include('handle', 'path')
|
|
107
107
|
expect(flat[:description]).to include('handle', 'path')
|
|
108
108
|
end
|
|
109
|
+
|
|
110
|
+
it 'does not force tool use on Anthropic models that reject tool_choice any and tool' do
|
|
111
|
+
allow(PWN::Env).to receive(:[]).with(:ai).and_return(
|
|
112
|
+
anthropic: { key: 'test-key', model: 'claude-mythos-5', temp: 1, max_tokens: 100 }
|
|
113
|
+
)
|
|
114
|
+
allow(PWN::AI::Agent::PromptCache).to receive(:enabled?).and_return(false) if defined?(PWN::AI::Agent::PromptCache)
|
|
115
|
+
captured = nil
|
|
116
|
+
allow(described_class).to receive(:anthropic_rest_call) do |opts|
|
|
117
|
+
captured = opts[:http_body]
|
|
118
|
+
'{"id":"msg_choice","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn"}'
|
|
119
|
+
end
|
|
120
|
+
tools = [{ type: 'function', function: { name: 'memory_recall', description: 'recall', parameters: { type: 'object', properties: {} } } }]
|
|
121
|
+
messages = [{ role: 'user', content: 'hello' }]
|
|
122
|
+
|
|
123
|
+
described_class.chat_with_tools(messages: messages, tools: tools, model: 'claude-mythos-5', tool_choice: 'required')
|
|
124
|
+
expect(captured[:tool_choice]).to eq(type: 'any')
|
|
125
|
+
|
|
126
|
+
%w[claude-mythos-5-1 claude-fable-5-1 claude-opus-5-5 claude-sonnet-5-5].each do |model|
|
|
127
|
+
described_class.chat_with_tools(messages: messages, tools: tools, model: model, tool_choice: 'required')
|
|
128
|
+
expect(captured[:tool_choice]).to eq(type: 'auto'), model
|
|
129
|
+
described_class.chat_with_tools(messages: messages, tools: tools, model: model, tool_choice: { type: 'function', function: { name: 'memory_recall' } })
|
|
130
|
+
expect(captured[:tool_choice]).to eq(type: 'auto'), model
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
described_class.chat_with_tools(messages: messages, tools: tools, model: 'claude-mythos-5-1', tool_choice: 'none')
|
|
134
|
+
expect(captured[:tool_choice]).to eq(type: 'none')
|
|
135
|
+
end
|
|
109
136
|
end
|
|
@@ -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/)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe PWN::AI::ModelCatalog do
|
|
6
|
+
it 'finds a named row across provider catalog shapes' do
|
|
7
|
+
grok = described_class.find_row(models: [{ id: 'grok-4.7', aliases: ['grok-latest'] }], name: 'grok-latest')
|
|
8
|
+
expect(grok[:id]).to eq('grok-4.7')
|
|
9
|
+
|
|
10
|
+
openai = described_class.find_row(models: { models: [{ slug: 'gpt-5.5' }] }, name: 'gpt-5.5')
|
|
11
|
+
expect(openai[:slug]).to eq('gpt-5.5')
|
|
12
|
+
|
|
13
|
+
gemini = described_class.find_row(models: [{ name: 'models/gemini-2.5-pro' }], name: 'gemini-2.5-pro')
|
|
14
|
+
expect(gemini[:name]).to eq('models/gemini-2.5-pro')
|
|
15
|
+
|
|
16
|
+
ollama = described_class.find_row(models: { models: [{ name: 'llama3.2', model: 'llama3.2:latest' }] }, name: 'llama3.2')
|
|
17
|
+
expect(ollama[:model]).to eq('llama3.2:latest')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'rejects error strings and keeps a short non-interactive lookup' do
|
|
21
|
+
expect(described_class.parse_row(raw: 'ERROR: timed out')).to be_nil
|
|
22
|
+
expect(described_class.model_row?(row: { error: 'missing' })).to be false
|
|
23
|
+
expect(described_class.lookup_opts(timeout: 8)[:non_interactive]).to be true
|
|
24
|
+
expect(described_class.lookup_opts(timeout: 8)[:timeout]).to eq(8)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'provider get_model routes' do
|
|
6
|
+
it 'asks each engine for one model on that engine route' do
|
|
7
|
+
allow(PWN::AI::Anthropic).to receive(:anthropic_rest_call).and_return('{"id":"claude-mythos-5","type":"model"}')
|
|
8
|
+
expect(PWN::AI::Anthropic.get_model(name: 'claude-mythos-5')[:id]).to eq('claude-mythos-5')
|
|
9
|
+
expect(PWN::AI::Anthropic).to have_received(:anthropic_rest_call).with(hash_including(rest_call: 'models/claude-mythos-5'))
|
|
10
|
+
|
|
11
|
+
allow(PWN::AI::Gemini).to receive(:gemini_rest_call).and_return('{"name":"models/gemini-2.5-pro"}')
|
|
12
|
+
expect(PWN::AI::Gemini.get_model(name: 'models/gemini-2.5-pro')[:name]).to eq('models/gemini-2.5-pro')
|
|
13
|
+
expect(PWN::AI::Gemini).to have_received(:gemini_rest_call).with(hash_including(rest_call: 'models/gemini-2.5-pro'))
|
|
14
|
+
|
|
15
|
+
allow(PWN::AI::Ollama).to receive(:ollama_rest_call).and_return('{"name":"llama3.2","model":"llama3.2"}')
|
|
16
|
+
expect(PWN::AI::Ollama.get_model(name: 'llama3.2')[:model]).to eq('llama3.2')
|
|
17
|
+
expect(PWN::AI::Ollama).to have_received(:ollama_rest_call).with(hash_including(http_method: :post, rest_call: 'api/show'))
|
|
18
|
+
|
|
19
|
+
allow(PWN::AI::OpenAI).to receive(:open_ai_rest_call).and_return('{"slug":"gpt-5.5"}')
|
|
20
|
+
expect(PWN::AI::OpenAI.get_model(name: 'gpt-5.5')[:slug]).to eq('gpt-5.5')
|
|
21
|
+
expect(PWN::AI::OpenAI).to have_received(:open_ai_rest_call).with(hash_including(rest_call: 'models/gpt-5.5'))
|
|
22
|
+
|
|
23
|
+
allow(PWN::AI::OpenWebUI).to receive(:openwebui_rest_call).and_return('{"id":"proxy-model"}')
|
|
24
|
+
expect(PWN::AI::OpenWebUI.get_model(name: 'proxy-model')[:id]).to eq('proxy-model')
|
|
25
|
+
expect(PWN::AI::OpenWebUI).to have_received(:openwebui_rest_call).with(hash_including(rest_call: 'api/v1/models/proxy-model'))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -91,13 +91,22 @@ describe PWN::Plugins::BinaryParser do
|
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
it 'elf_resolve points system at the .plt.sec stub, not the next slot' do
|
|
94
|
+
x86_64 = RbConfig::CONFIG['host_cpu'].to_s.match?(/amd64|x86_64/)
|
|
94
95
|
Dir.mktmpdir('pwn-elf-ibt-') do |dir|
|
|
95
96
|
src = File.join(dir, 't.c')
|
|
96
97
|
path = File.join(dir, 't')
|
|
97
98
|
File.write(src, "#include <stdlib.h>\nint main(void) { return system(\"x\"); }\n")
|
|
98
|
-
|
|
99
|
+
flags = ['-O0', '-fno-pie', '-no-pie']
|
|
100
|
+
flags << '-Wl,-z,ibt' if x86_64
|
|
101
|
+
out, status = Open3.capture2e('cc', *flags, '-o', path, src)
|
|
99
102
|
expect(status.success?).to eq(true), out
|
|
100
|
-
|
|
103
|
+
binary = File.binread(path)
|
|
104
|
+
unless x86_64
|
|
105
|
+
expect(binary).not_to include('.plt.sec')
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
expect(binary).to include('.plt.sec')
|
|
101
110
|
row = described_class.elf_resolve(path: path)
|
|
102
111
|
dump, dump_status = Open3.capture2('objdump', '-d', path)
|
|
103
112
|
expect(dump_status.success?).to eq(true), dump
|