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.
@@ -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 = PWN::Env[:ai][:openwebui]
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 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.758'
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/)