pwn 0.5.756 → 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.
@@ -686,7 +686,7 @@ module PWN
686
686
 
687
687
  return clear_view if key == "\u000c"
688
688
 
689
- if key == "\u0007"
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 key == "\u0018"
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
- @scroll = [@scroll.to_i + @page_size.to_i, @total_rows.to_i].min
761
- @scroll = nil if @scroll >= [@total_rows.to_i - @page_size.to_i, 0].max
762
- @unseen = 0 unless @scroll
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([@height - 13, 1].max)
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
- compose_height = 6
1310
- timeline_height = @height - compose_height - 1 - header_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 - 1
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
- hint = if @menu
1329
- '↑↓/^P/^N select · Tab accept · Esc close'
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
- first = [cursor_row - 2, 0].max
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, 3).to_a.each_with_index { |row, index| put(top + 1 + index, 4, row, tone(:composer)) }
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
- wrap(match, @width - 8).first(2).each_with_index { |row, index| put(top + 2 + index, 4, row, tone(:composer)) }
1372
- put(top + 4, 4, '^R older · Enter accept · Esc cancel', tone(:footer))
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+X switches the active pane between SESSION and MISSION CONTROL.
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 and explicitly configured USD estimates.
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
- price = AIConsoleUsage.estimate(opts.merge(usage: usage))
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
- usage = opts[:usage] || normalize(opts)
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 only from explicit nonnegative rates; nil means unavailable, never zero.
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 used to read configured rates',
114
- model: 'optional - model key under that provider pricing hash'
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.756'
4
+ VERSION = '0.5.757'
5
5
  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
@@ -967,7 +967,18 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
967
967
  expect(console.instance_variable_get(:@scroll)).to eq(0)
968
968
  console.handle(:end)
969
969
  expect(console.instance_variable_get(:@scroll)).to be_nil
970
+ console.handle(:page_up)
971
+ expect(console.instance_variable_get(:@scroll)).to eq(60)
972
+ console.handle(:page_down)
973
+ expect(console.instance_variable_get(:@scroll)).to be_nil
974
+ console.handle(:page_down)
975
+ expect(console.instance_variable_get(:@scroll)).to be_nil
976
+ console.handle(:home)
977
+ expect(console.instance_variable_get(:@scroll)).to eq(0)
978
+ console.handle(:page_up)
979
+ expect(console.instance_variable_get(:@scroll)).to eq(0)
970
980
  expect(console.instance_variable_get(:@unseen)).to eq(0)
981
+ console.handle(:end)
971
982
  console.handle(:up)
972
983
  console.handle(:down)
973
984
  expect(console.instance_variable_get(:@scroll)).to be_nil
@@ -1003,6 +1014,38 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1003
1014
  expect(screen).to have_received(:setpos).with(a_kind_of(Integer), 2)
1004
1015
  end
1005
1016
 
1017
+ it 'toggles the pane with Ctrl+T and opens swarm with Ctrl+S' do
1018
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1019
+ console.handle("\u0014")
1020
+ expect(console.instance_variable_get(:@focus)).to eq(:session)
1021
+ console.handle("\u0013")
1022
+ expect(console.instance_variable_get(:@workspace)).to be_a(Hash)
1023
+ console.handle("\u0013")
1024
+ expect(console.instance_variable_get(:@workspace)).to be_nil
1025
+ end
1026
+
1027
+ it 'wraps the footer inside the window and omits the composer hint line' do
1028
+ screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
1029
+ curses = double(lines: 24, cols: 48)
1030
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
1031
+ console.instance_variable_set(:@screen, screen)
1032
+ painted = []
1033
+ allow(console).to receive(:put) { |*args| painted << args }
1034
+ console.draw
1035
+ text = painted.map { |row| row[2].to_s }.join("\n")
1036
+ expect(text).not_to include('^X pane')
1037
+ expect(text).to include('^T=toggle pane', '^S=swarm', 'Shift+Enter=newline', 'Enter=send')
1038
+ footer = painted.select { |row| row[2].to_s.include?('^T=toggle pane') || row[2].to_s.include?('Enter=send') || row[2].to_s.include?('/ menu') }
1039
+ expect(footer.length).to be > 1
1040
+ expect(footer).to all(satisfy { |_y, _x, line, _color| console.width(line) <= 46 })
1041
+ console.handle("\u0014")
1042
+ painted.clear
1043
+ console.draw
1044
+ session = painted.map { |row| row[2].to_s }.join
1045
+ expect(session).to include('↑↓ scroll · HOME · PGUP · PGDN · END')
1046
+ expect(session).not_to include('↑↓ history')
1047
+ end
1048
+
1006
1049
  it 'wraps at words without discarding code whitespace or splitting graphemes' do
1007
1050
  console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1008
1051
  expect(console.wrap('some evidence', 12)).to eq(['some ', 'evidence'])
@@ -1103,8 +1146,9 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1103
1146
  resized = false
1104
1147
  launch([]) do
1105
1148
  if resized
1106
- expect(@paint.last).to include('^C cancel', '^D back')
1107
- expect(Unicode::DisplayWidth.of(@paint.last)).to be <= 46
1149
+ footer = @paint.select { |line| (line.include?('^C=cancel') || line.include?('Enter=send')) && Unicode::DisplayWidth.of(line) <= 46 }
1150
+ expect(footer.join).to include('^C=cancel', '^D=back')
1151
+ expect(footer.length).to be > 1
1108
1152
  "\u0004"
1109
1153
  else
1110
1154
  allow(@screen).to receive_messages(lines: 14, cols: 48)