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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 004ad4f77955698492a48591db3dad3f54d141fb94ddb7952211e86dc4945526
|
|
4
|
+
data.tar.gz: 5c76e0a2f71fdc1afd13f8af828c2027cb8183f3e73612d81f220b864b6df636
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ffe44c731211488e5d07938e29dc1c503307801de30d0c2effc55d139d0f00b0a40a7deeda375f05b581ea88e379b9b82f031b589236bd5a08edd590078b3511
|
|
7
|
+
data.tar.gz: 9c8275fd41e044aed262bcbe7fcf9fe00694b43227030ba1ce90d907c6e68b54efa53dacd3c3cb712890acc159005f5ecd4c9cf1caf065de8032cfc4123041de
|
data/Gemfile
CHANGED
|
@@ -117,7 +117,8 @@ changes only default values, not the configuration schema; no migration is neede
|
|
|
117
117
|
| Shift+Enter, or trailing `\\` + Enter | Insert a newline. Terminals that cannot distinguish Shift+Enter need `tmux set -s extended-keys on` or the backslash fallback. |
|
|
118
118
|
| Up/Down | In MISSION CONTROL, move the completion highlight while the Command menu is visible (wrapping at either end). Otherwise recall requests from `~/.pwn/pwn_history`, including prior runs; Down past the newest restores the draft and cursor. In SESSION, move one transcript row. Esc closes the menu to resume history recall. |
|
|
119
119
|
| Home/End | In MISSION CONTROL, move the composer cursor to the start or end of the line. In SESSION, jump to the top of the transcript or back to the live bottom. |
|
|
120
|
-
| Ctrl+
|
|
120
|
+
| Ctrl+T | Toggle the active pane between SESSION and MISSION CONTROL. Ctrl+X still toggles. The active title is reversed and marked `active`. Typing or Enter returns to MISSION CONTROL; Enter does not submit while SESSION is active. |
|
|
121
|
+
| Ctrl+S or Ctrl+G or `/swarm` | Open the draft-preserving swarm workspace; `/swarm dashboard` also opens it. Ctrl+S still saves inside the system-role editor. |
|
|
121
122
|
| Ctrl+P / Ctrl+N | Alternative completion selection keys. In MISSION CONTROL they recall history when no menu is visible. In SESSION they scroll one row. Tab still accepts the highlight. |
|
|
122
123
|
| Tab | Accept the highlighted as-you-type parameter |
|
|
123
124
|
| Ctrl+O or `/status` | Inspect full status/settings, including overflowing header text; Esc or Ctrl+O closes |
|
|
@@ -125,7 +126,6 @@ changes only default values, not the configuration schema; no migration is neede
|
|
|
125
126
|
| Ctrl+R | Incremental reverse search of request history from `~/.pwn/pwn_history`; repeat for an older match, Enter accepts into the draft without sending, Esc cancels and restores the draft/cursor |
|
|
126
127
|
| `/clear` | Clear the session pane only; stored conversation and token totals remain |
|
|
127
128
|
| `/verbose [on|off]` | Toggle compact or full tool/notice output |
|
|
128
|
-
| Ctrl+G or `/swarm` | Open the draft-preserving swarm workspace; `/swarm dashboard` also opens it |
|
|
129
129
|
| `/` + Enter or `/menu` | Open the slash menu |
|
|
130
130
|
| PgUp/PgDn | Scroll the timeline; new output does not pull a scrolled viewport to the bottom |
|
|
131
131
|
| `/model`, `/sessions resume ID` | Show model settings / change the next request's model or session while idle |
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pwn-ai-modelcatalog
|
|
3
|
+
description: Drive PWN::AI::ModelCatalog from pwn_eval.
|
|
4
|
+
license: MIT
|
|
5
|
+
allowed-tools: [pwn, pwn_eval]
|
|
6
|
+
metadata:
|
|
7
|
+
bundled: true
|
|
8
|
+
generated: true
|
|
9
|
+
module: PWN::AI::ModelCatalog
|
|
10
|
+
source: pwn/ai/model_catalog.rb
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# PWN::AI::ModelCatalog
|
|
14
|
+
|
|
15
|
+
Shared catalog lookup for provider model records. Each provider still fetches its own payload; this only finds one named row without a second network call when the caller already has the list.
|
|
16
|
+
|
|
17
|
+
## When to use
|
|
18
|
+
|
|
19
|
+
Call `PWN::AI::ModelCatalog` from `pwn_eval` when the task needs this module.
|
|
20
|
+
Do not reimplement it in shell.
|
|
21
|
+
|
|
22
|
+
## Methodologies
|
|
23
|
+
|
|
24
|
+
Generated from `pwn/ai/model_catalog.rb`. Prefer the public class methods below.
|
|
25
|
+
Class methods take `(opts = {})` and read `opts`.
|
|
26
|
+
|
|
27
|
+
## How to call
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
PWN::AI::ModelCatalog.help
|
|
31
|
+
PWN::AI::ModelCatalog.find_row(opts)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Public methods
|
|
35
|
+
|
|
36
|
+
- `find_row`
|
|
37
|
+
- `catalog_rows`
|
|
38
|
+
- `model_row`
|
|
39
|
+
- `parse_row`
|
|
40
|
+
- `lookup_opts`
|
|
41
|
+
- `authors`
|
|
42
|
+
- `help`
|
|
43
|
+
- `model_row?`
|
|
44
|
+
|
|
45
|
+
## Source
|
|
46
|
+
|
|
47
|
+
`pwn/ai/model_catalog.rb`
|
|
48
|
+
|
|
49
|
+
## Verification
|
|
50
|
+
|
|
51
|
+
`PWN::AI::ModelCatalog.respond_to?(:find_row)` after the
|
|
52
|
+
module is loaded. Read the source for parameter names.
|
|
@@ -12,7 +12,7 @@ metadata:
|
|
|
12
12
|
|
|
13
13
|
# PWN::Plugins::REPL::AIConsoleUsage
|
|
14
14
|
|
|
15
|
-
Provider-reported token totals
|
|
15
|
+
Provider-reported token totals. USD comes from the active model's published prices, then from explicitly configured rates. Missing prices stay unavailable.
|
|
16
16
|
|
|
17
17
|
## When to use
|
|
18
18
|
|
data/lib/pwn/ai/anthropic.rb
CHANGED
|
@@ -510,6 +510,30 @@ module PWN
|
|
|
510
510
|
raise e
|
|
511
511
|
end
|
|
512
512
|
|
|
513
|
+
# Supported Method Parameters::
|
|
514
|
+
# model = PWN::AI::Anthropic.get_model(name: 'required - model id')
|
|
515
|
+
#
|
|
516
|
+
# The Models API lists id and capabilities. Pricing, when Anthropic
|
|
517
|
+
# includes it on that record, is passed through unchanged. A missing
|
|
518
|
+
# price field is not filled from a public price table.
|
|
519
|
+
|
|
520
|
+
public_class_method def self.get_model(opts = {})
|
|
521
|
+
name = opts[:name].to_s.strip
|
|
522
|
+
raise 'ERROR: Model name is required' if name.empty?
|
|
523
|
+
|
|
524
|
+
hop = PWN::AI::ModelCatalog.lookup_opts(opts)
|
|
525
|
+
row = PWN::AI::ModelCatalog.parse_row(raw: anthropic_rest_call(hop.merge(rest_call: "models/#{URI.encode_www_form_component(name)}")))
|
|
526
|
+
return row if PWN::AI::ModelCatalog.model_row?(row: row)
|
|
527
|
+
return nil if opts[:fallback] == false
|
|
528
|
+
|
|
529
|
+
listed = PWN::AI::ModelCatalog.parse_row(raw: anthropic_rest_call(hop.merge(rest_call: 'models')))
|
|
530
|
+
PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? listed[:data] : listed, name: name)
|
|
531
|
+
rescue StandardError => e
|
|
532
|
+
raise e if name.empty?
|
|
533
|
+
|
|
534
|
+
nil
|
|
535
|
+
end
|
|
536
|
+
|
|
513
537
|
# ----------------------------------------------------------------------
|
|
514
538
|
# Native tool-calling adapter for PWN::AI::Agent::Loop.
|
|
515
539
|
#
|
|
@@ -971,6 +995,13 @@ module PWN
|
|
|
971
995
|
# Run get models and return its result
|
|
972
996
|
#{self}.get_models
|
|
973
997
|
|
|
998
|
+
# Return one model record. Pricing is included only when that record publishes it.
|
|
999
|
+
#{self}.get_model(
|
|
1000
|
+
name: 'required - model id',
|
|
1001
|
+
timeout: 'optional - seconds (default 15)',
|
|
1002
|
+
fallback: 'optional - false skips the full catalog when the direct route misses'
|
|
1003
|
+
)
|
|
1004
|
+
|
|
974
1005
|
# Native tool-calling adapter for PWN::AI::Agent::Loop
|
|
975
1006
|
#{self}.chat_with_tools(
|
|
976
1007
|
assistant_message: 'optional - <same hash> }',
|
data/lib/pwn/ai/gemini.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'rest-client'
|
|
5
5
|
require 'securerandom'
|
|
6
|
+
require 'uri'
|
|
6
7
|
|
|
7
8
|
module PWN
|
|
8
9
|
module AI
|
|
@@ -134,6 +135,30 @@ module PWN
|
|
|
134
135
|
raise e
|
|
135
136
|
end
|
|
136
137
|
|
|
138
|
+
# Supported Method Parameters::
|
|
139
|
+
# model = PWN::AI::Gemini.get_model(name: 'required - model id or models/<id>')
|
|
140
|
+
#
|
|
141
|
+
# Gemini names models as models/<id> and usually publishes token limits,
|
|
142
|
+
# not USD rates. A pricing object on the record is kept; none is invented.
|
|
143
|
+
|
|
144
|
+
public_class_method def self.get_model(opts = {})
|
|
145
|
+
name = opts[:name].to_s.strip
|
|
146
|
+
raise 'ERROR: Model name is required' if name.empty?
|
|
147
|
+
|
|
148
|
+
hop = PWN::AI::ModelCatalog.lookup_opts(opts)
|
|
149
|
+
id = name.delete_prefix('models/')
|
|
150
|
+
row = PWN::AI::ModelCatalog.parse_row(raw: gemini_rest_call(hop.merge(rest_call: "models/#{URI.encode_www_form_component(id)}")))
|
|
151
|
+
return row if PWN::AI::ModelCatalog.model_row?(row: row)
|
|
152
|
+
return nil if opts[:fallback] == false
|
|
153
|
+
|
|
154
|
+
listed = PWN::AI::ModelCatalog.parse_row(raw: gemini_rest_call(hop.merge(rest_call: 'models')))
|
|
155
|
+
PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? listed[:models] : listed, name: name)
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
raise e if name.empty?
|
|
158
|
+
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
137
162
|
# ----------------------------------------------------------------------
|
|
138
163
|
# Native tool-calling adapter for PWN::AI::Agent::Loop.
|
|
139
164
|
#
|
|
@@ -460,6 +485,13 @@ module PWN
|
|
|
460
485
|
# Run get models and return its result
|
|
461
486
|
#{self}.get_models
|
|
462
487
|
|
|
488
|
+
# Return one model record. Gemini usually publishes token limits, not USD rates.
|
|
489
|
+
#{self}.get_model(
|
|
490
|
+
name: 'required - model id or models/<id>',
|
|
491
|
+
timeout: 'optional - seconds (default 15)',
|
|
492
|
+
fallback: 'optional - false skips the full catalog when the direct route misses'
|
|
493
|
+
)
|
|
494
|
+
|
|
463
495
|
# Native tool-calling adapter for PWN::AI::Agent::Loop
|
|
464
496
|
#{self}.chat_with_tools(
|
|
465
497
|
assistant_message: 'optional - <same hash> }',
|
data/lib/pwn/ai/grok.rb
CHANGED
|
@@ -525,6 +525,30 @@ module PWN
|
|
|
525
525
|
raise e
|
|
526
526
|
end
|
|
527
527
|
|
|
528
|
+
# Supported Method Parameters::
|
|
529
|
+
# model = PWN::AI::Grok.get_model(name: 'required - model id')
|
|
530
|
+
#
|
|
531
|
+
# xAI prices are integer USD cents per 100 million tokens on the model
|
|
532
|
+
# record itself (prompt/completion/cached). Zero means that component
|
|
533
|
+
# is unpublished, not free.
|
|
534
|
+
|
|
535
|
+
public_class_method def self.get_model(opts = {})
|
|
536
|
+
name = opts[:name].to_s.strip
|
|
537
|
+
raise 'ERROR: Model name is required' if name.empty?
|
|
538
|
+
|
|
539
|
+
hop = PWN::AI::ModelCatalog.lookup_opts(opts)
|
|
540
|
+
row = PWN::AI::ModelCatalog.parse_row(raw: grok_rest_call(hop.merge(rest_call: "models/#{URI.encode_www_form_component(name)}")))
|
|
541
|
+
return row if PWN::AI::ModelCatalog.model_row?(row: row)
|
|
542
|
+
return nil if opts[:fallback] == false
|
|
543
|
+
|
|
544
|
+
listed = PWN::AI::ModelCatalog.parse_row(raw: grok_rest_call(hop.merge(rest_call: 'models')))
|
|
545
|
+
PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? listed[:data] : listed, name: name)
|
|
546
|
+
rescue StandardError => e
|
|
547
|
+
raise e if name.empty?
|
|
548
|
+
|
|
549
|
+
nil
|
|
550
|
+
end
|
|
551
|
+
|
|
528
552
|
# Supported Method Parameters::
|
|
529
553
|
# response = PWN::AI::Grok.chat_with_tools(
|
|
530
554
|
# messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
|
|
@@ -741,6 +765,13 @@ module PWN
|
|
|
741
765
|
# Run get models and return its result
|
|
742
766
|
#{self}.get_models
|
|
743
767
|
|
|
768
|
+
# Return one model record, including published token prices when the provider sends them.
|
|
769
|
+
#{self}.get_model(
|
|
770
|
+
name: 'required - model id',
|
|
771
|
+
timeout: 'optional - seconds (default 15)',
|
|
772
|
+
fallback: 'optional - false skips the full catalog when the direct route misses'
|
|
773
|
+
)
|
|
774
|
+
|
|
744
775
|
# Run chat with tools and return its result
|
|
745
776
|
#{self}.chat_with_tools(
|
|
746
777
|
messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PWN
|
|
6
|
+
module AI
|
|
7
|
+
# Shared catalog lookup for provider model records. Each provider still
|
|
8
|
+
# fetches its own payload; this only finds one named row without a second
|
|
9
|
+
# network call when the caller already has the list.
|
|
10
|
+
module ModelCatalog
|
|
11
|
+
# Return the catalog row whose id, slug, name, or model matches name.
|
|
12
|
+
public_class_method def self.find_row(opts = {})
|
|
13
|
+
name = opts[:name].to_s.strip
|
|
14
|
+
return nil if name.empty?
|
|
15
|
+
|
|
16
|
+
rows = catalog_rows(models: opts[:models])
|
|
17
|
+
wanted = name.delete_prefix('models/')
|
|
18
|
+
rows.find do |row|
|
|
19
|
+
next false unless row.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
ids = [
|
|
22
|
+
row[:id], row['id'], row[:slug], row['slug'], row[:name], row['name'],
|
|
23
|
+
row[:model], row['model'], row[:display_name], row['display_name']
|
|
24
|
+
].compact.map { |value| value.to_s.delete_prefix('models/') }
|
|
25
|
+
aliases = row[:aliases] || row['aliases'] || []
|
|
26
|
+
ids.concat(Array(aliases).map(&:to_s))
|
|
27
|
+
ids.any? { |value| value == name || value == wanted }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Flatten the list shapes get_models already returns.
|
|
32
|
+
public_class_method def self.catalog_rows(opts = {})
|
|
33
|
+
raw = opts[:models]
|
|
34
|
+
case raw
|
|
35
|
+
when Array then raw
|
|
36
|
+
when Hash then raw[:data] || raw[:models] || raw['data'] || raw['models'] || []
|
|
37
|
+
else []
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# True when the hash is a model record rather than an error object.
|
|
42
|
+
public_class_method def self.model_row?(opts = {})
|
|
43
|
+
row = opts[:row]
|
|
44
|
+
return false unless row.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
%i[id slug name model].any? { |key| !(row[key] || row[key.to_s]).to_s.strip.empty? }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Parse a REST body into a Hash or Array. Error strings are not catalogs.
|
|
50
|
+
public_class_method def self.parse_row(opts = {})
|
|
51
|
+
raw = opts[:raw]
|
|
52
|
+
return raw if raw.is_a?(Hash) || raw.is_a?(Array)
|
|
53
|
+
return nil if raw.nil?
|
|
54
|
+
return nil unless raw.is_a?(String) || raw.respond_to?(:to_str)
|
|
55
|
+
|
|
56
|
+
parsed = JSON.parse(raw.to_s, symbolize_names: true)
|
|
57
|
+
parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : nil
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Short, non-interactive hop so a price lookup cannot prompt or retry for minutes.
|
|
63
|
+
public_class_method def self.lookup_opts(opts = {})
|
|
64
|
+
timeout = opts[:timeout].to_i
|
|
65
|
+
timeout = 15 unless timeout.positive?
|
|
66
|
+
{
|
|
67
|
+
timeout: timeout,
|
|
68
|
+
non_interactive: opts.fetch(:non_interactive, true),
|
|
69
|
+
quiet: true,
|
|
70
|
+
spinner: false
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
public_class_method def self.authors
|
|
75
|
+
'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
public_class_method def self.help
|
|
79
|
+
puts "USAGE:
|
|
80
|
+
# Find one catalog row by id, slug, name, model, or alias.
|
|
81
|
+
#{self}.find_row(
|
|
82
|
+
models: 'required - get_models payload',
|
|
83
|
+
name: 'required - model id'
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Flatten a provider catalog into row hashes.
|
|
87
|
+
#{self}.catalog_rows(
|
|
88
|
+
models: 'required - get_models payload'
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# True when the hash is a model record rather than an error object.
|
|
92
|
+
#{self}.model_row?(
|
|
93
|
+
row: 'required - catalog row hash to test'
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Parse one REST model body. Error strings are not catalogs.
|
|
97
|
+
#{self}.parse_row(
|
|
98
|
+
raw: 'required - response body'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Short non-interactive options for a price lookup.
|
|
102
|
+
#{self}.lookup_opts(
|
|
103
|
+
timeout: 'optional - seconds (default 15, one attempt)',
|
|
104
|
+
non_interactive: 'optional - never prompt (default true)'
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Return module authors.
|
|
108
|
+
#{self}.authors
|
|
109
|
+
"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/pwn/ai/ollama.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
|
|
@@ -405,11 +406,39 @@ module PWN
|
|
|
405
406
|
raise e
|
|
406
407
|
end
|
|
407
408
|
|
|
409
|
+
# Supported Method Parameters::
|
|
410
|
+
# model = PWN::AI::Ollama.get_model(name: 'required - tag name')
|
|
411
|
+
#
|
|
412
|
+
# Local tags have size and family, not token prices. get_model reads
|
|
413
|
+
# /api/show for that tag and returns it. Missing pricing stays missing.
|
|
414
|
+
|
|
415
|
+
public_class_method def self.get_model(opts = {})
|
|
416
|
+
name = opts[:name].to_s.strip
|
|
417
|
+
raise 'ERROR: Model name is required' if name.empty?
|
|
418
|
+
|
|
419
|
+
hop = PWN::AI::ModelCatalog.lookup_opts(opts)
|
|
420
|
+
row = PWN::AI::ModelCatalog.parse_row(
|
|
421
|
+
raw: ollama_rest_call(hop.merge(http_method: :post, rest_call: 'api/show', http_body: { model: name }))
|
|
422
|
+
)
|
|
423
|
+
return row if PWN::AI::ModelCatalog.model_row?(row: row)
|
|
424
|
+
return nil if opts[:fallback] == false
|
|
425
|
+
|
|
426
|
+
listed = PWN::AI::ModelCatalog.parse_row(raw: ollama_rest_call(hop.merge(rest_call: 'api/tags')))
|
|
427
|
+
PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? listed[:models] : listed, name: name)
|
|
428
|
+
rescue StandardError => e
|
|
429
|
+
raise e if name.empty?
|
|
430
|
+
|
|
431
|
+
nil
|
|
432
|
+
end
|
|
433
|
+
|
|
408
434
|
# Coerce OpenAI-wire message history into Ollama-native shapes before
|
|
409
435
|
# POST /api/chat (or Open WebUI /ollama/api/chat):
|
|
410
436
|
# - function.arguments must be a Hash/Array object, not a JSON string
|
|
411
437
|
# (string args → HTTP 400 "can't find closing '}' symbol")
|
|
412
438
|
# - assistant content nil + tool_calls → "" (Open WebUI form validation)
|
|
439
|
+
# - system/developer rows after the first message are merged into one
|
|
440
|
+
# leading system message. Qwen-style templates raise HTTP 500
|
|
441
|
+
# "System message must be at the beginning" otherwise.
|
|
413
442
|
private_class_method def self.parse_tool_arguments_object(opts = {})
|
|
414
443
|
raw = opts[:arguments]
|
|
415
444
|
case raw
|
|
@@ -432,7 +461,7 @@ module PWN
|
|
|
432
461
|
end
|
|
433
462
|
|
|
434
463
|
private_class_method def self.normalize_messages_for_ollama(opts = {})
|
|
435
|
-
Array(opts[:messages]).filter_map do |m|
|
|
464
|
+
rows = Array(opts[:messages]).filter_map do |m|
|
|
436
465
|
next unless m.is_a?(Hash)
|
|
437
466
|
|
|
438
467
|
role = (m[:role] || m['role']).to_s
|
|
@@ -482,6 +511,71 @@ module PWN
|
|
|
482
511
|
|
|
483
512
|
out
|
|
484
513
|
end
|
|
514
|
+
lead_system(messages: rows, fold_into_user: opts[:fold_into_user])
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# One system role is only safe when the server does not already prepend one.
|
|
518
|
+
# Models whose template raises "System message must be at the beginning"
|
|
519
|
+
# already carry a Modelfile SYSTEM prompt. Fold client system text into
|
|
520
|
+
# the first user message instead of sending a second system role.
|
|
521
|
+
private_class_method def self.lead_system(opts = {})
|
|
522
|
+
parts = []
|
|
523
|
+
rest = []
|
|
524
|
+
Array(opts[:messages]).each do |row|
|
|
525
|
+
next unless row.is_a?(Hash)
|
|
526
|
+
|
|
527
|
+
role = (row[:role] || row['role']).to_s
|
|
528
|
+
if %w[system developer].include?(role)
|
|
529
|
+
text = (row.key?(:content) ? row[:content] : row['content']).to_s
|
|
530
|
+
parts << text unless text.strip.empty?
|
|
531
|
+
else
|
|
532
|
+
rest << row
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
return rest if parts.empty?
|
|
536
|
+
|
|
537
|
+
text = parts.join("\n\n")
|
|
538
|
+
return [{ role: 'system', content: text }] + rest unless opts[:fold_into_user]
|
|
539
|
+
|
|
540
|
+
user = rest.find { |row| (row[:role] || row['role']).to_s == 'user' }
|
|
541
|
+
if user
|
|
542
|
+
body = (user.key?(:content) ? user[:content] : user['content']).to_s
|
|
543
|
+
user[:content] = "#{text}\n\n#{body}"
|
|
544
|
+
user.delete('content')
|
|
545
|
+
rest
|
|
546
|
+
else
|
|
547
|
+
[{ role: 'user', content: text }] + rest
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
private_class_method def self.strict_system_template?(opts = {})
|
|
552
|
+
name = opts[:model].to_s.strip
|
|
553
|
+
return false if name.empty?
|
|
554
|
+
|
|
555
|
+
@strict_system_cache ||= {}
|
|
556
|
+
return @strict_system_cache[name] if @strict_system_cache.key?(name)
|
|
557
|
+
|
|
558
|
+
@strict_system_cache[name] = template_rejects_later_system?(model: name)
|
|
559
|
+
rescue StandardError
|
|
560
|
+
false
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
private_class_method def self.template_rejects_later_system?(opts = {})
|
|
564
|
+
name = opts[:model].to_s
|
|
565
|
+
names = [name]
|
|
566
|
+
names << "#{name}:latest" unless name.include?(':')
|
|
567
|
+
names.each do |candidate|
|
|
568
|
+
raw = ollama_rest_call(
|
|
569
|
+
http_method: :post, rest_call: 'api/show',
|
|
570
|
+
http_body: { model: candidate }, timeout: 8, quiet: true, non_interactive: true
|
|
571
|
+
)
|
|
572
|
+
template = PWN::AI::ModelCatalog.parse_row(raw: raw)
|
|
573
|
+
text = template.is_a?(Hash) ? (template[:template] || template['template']).to_s : ''
|
|
574
|
+
return true if text.include?('System message must be at the beginning')
|
|
575
|
+
end
|
|
576
|
+
false
|
|
577
|
+
rescue StandardError
|
|
578
|
+
false
|
|
485
579
|
end
|
|
486
580
|
|
|
487
581
|
# Supported Method Parameters::
|
|
@@ -500,13 +594,16 @@ module PWN
|
|
|
500
594
|
# NDJSON chunks back into a single response.
|
|
501
595
|
|
|
502
596
|
public_class_method def self.chat_with_tools(opts = {})
|
|
503
|
-
engine
|
|
504
|
-
messages = normalize_messages_for_ollama(messages: opts[:messages])
|
|
505
|
-
raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
|
|
506
|
-
|
|
597
|
+
engine = PWN::Env[:ai][:ollama]
|
|
507
598
|
model = opts[:model] ||= engine[:model]
|
|
508
599
|
raise 'ERROR: Model is required. Call #get_models method for details' if model.nil?
|
|
509
600
|
|
|
601
|
+
messages = normalize_messages_for_ollama(
|
|
602
|
+
messages: opts[:messages],
|
|
603
|
+
fold_into_user: strict_system_template?(model: model)
|
|
604
|
+
)
|
|
605
|
+
raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
|
|
606
|
+
|
|
510
607
|
temp = opts[:temp].to_f
|
|
511
608
|
temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
|
|
512
609
|
|
|
@@ -630,6 +727,10 @@ module PWN
|
|
|
630
727
|
end
|
|
631
728
|
|
|
632
729
|
http_body[:messages].push(user_role)
|
|
730
|
+
http_body[:messages] = lead_system(
|
|
731
|
+
messages: http_body[:messages],
|
|
732
|
+
fold_into_user: strict_system_template?(model: model)
|
|
733
|
+
)
|
|
633
734
|
|
|
634
735
|
timeout = opts[:timeout]
|
|
635
736
|
spinner = opts[:spinner]
|
|
@@ -679,6 +780,13 @@ module PWN
|
|
|
679
780
|
# Run get models and return its result
|
|
680
781
|
#{self}.get_models
|
|
681
782
|
|
|
783
|
+
# Return one local tag via /api/show. Missing token prices stay missing.
|
|
784
|
+
#{self}.get_model(
|
|
785
|
+
name: 'required - tag name',
|
|
786
|
+
timeout: 'optional - seconds (default 15)',
|
|
787
|
+
fallback: 'optional - false skips /api/tags when /api/show misses'
|
|
788
|
+
)
|
|
789
|
+
|
|
682
790
|
# Run chat with tools and return its result
|
|
683
791
|
#{self}.chat_with_tools(
|
|
684
792
|
messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
|
data/lib/pwn/ai/open_ai.rb
CHANGED
|
@@ -593,6 +593,32 @@ module PWN
|
|
|
593
593
|
raise e
|
|
594
594
|
end
|
|
595
595
|
|
|
596
|
+
# Supported Method Parameters::
|
|
597
|
+
# model = PWN::AI::OpenAI.get_model(name: 'required - model id or slug')
|
|
598
|
+
#
|
|
599
|
+
# Platform catalogs use data[].id. ChatGPT/Codex catalogs use
|
|
600
|
+
# models[].slug and may nest pricing under a different key. get_model
|
|
601
|
+
# returns that one row; it does not invent a USD rate the catalog omitted.
|
|
602
|
+
|
|
603
|
+
public_class_method def self.get_model(opts = {})
|
|
604
|
+
name = opts[:name].to_s.strip
|
|
605
|
+
raise 'ERROR: Model name is required' if name.empty?
|
|
606
|
+
|
|
607
|
+
hop = PWN::AI::ModelCatalog.lookup_opts(opts)
|
|
608
|
+
row = PWN::AI::ModelCatalog.parse_row(raw: open_ai_rest_call(hop.merge(rest_call: "models/#{URI.encode_www_form_component(name)}")))
|
|
609
|
+
return row if PWN::AI::ModelCatalog.model_row?(row: row)
|
|
610
|
+
return nil if opts[:fallback] == false
|
|
611
|
+
|
|
612
|
+
listed = open_ai_rest_call(hop.merge(rest_call: 'models'))
|
|
613
|
+
return nil if listed.nil?
|
|
614
|
+
|
|
615
|
+
PWN::AI::ModelCatalog.find_row(models: catalog_models(raw: listed), name: name)
|
|
616
|
+
rescue StandardError => e
|
|
617
|
+
raise e if name.empty?
|
|
618
|
+
|
|
619
|
+
nil
|
|
620
|
+
end
|
|
621
|
+
|
|
596
622
|
private_class_method def self.catalog_models(opts = {})
|
|
597
623
|
raw = opts[:raw]
|
|
598
624
|
raw = JSON.parse(raw, symbolize_names: true) if raw.is_a?(String) || raw.respond_to?(:to_str)
|
|
@@ -1517,6 +1543,13 @@ module PWN
|
|
|
1517
1543
|
# Run get models and return its result
|
|
1518
1544
|
#{self}.get_models
|
|
1519
1545
|
|
|
1546
|
+
# Return one model row by id or slug. A missing price is not invented.
|
|
1547
|
+
#{self}.get_model(
|
|
1548
|
+
name: 'required - model id or slug',
|
|
1549
|
+
timeout: 'optional - seconds (default 15)',
|
|
1550
|
+
fallback: 'optional - false skips the full catalog when the direct route misses'
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1520
1553
|
# Chat Completions vs Responses path for this model (tools may force Responses).
|
|
1521
1554
|
#{self}.api_endpoint(
|
|
1522
1555
|
model: 'required - OpenAI model id (e.g. gpt-6-astra or gpt-4o)',
|