smart_prompt 0.5.2 → 0.5.4
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/CHANGELOG.md +12 -0
- data/config/siliconflow_config.yml +95 -0
- data/examples/siliconflow_example.rb +175 -0
- data/lib/smart_prompt/adapters/siliconflow/embed.rb +33 -0
- data/lib/smart_prompt/adapters/siliconflow/image.rb +103 -0
- data/lib/smart_prompt/adapters/siliconflow/rerank.rb +41 -0
- data/lib/smart_prompt/adapters/siliconflow/text.rb +54 -0
- data/lib/smart_prompt/adapters/siliconflow/video.rb +111 -0
- data/lib/smart_prompt/adapters/siliconflow/voice.rb +102 -0
- data/lib/smart_prompt/adapters/zhipu/embed.rb +32 -0
- data/lib/smart_prompt/adapters/zhipu/image.rb +59 -0
- data/lib/smart_prompt/adapters/zhipu/rerank.rb +17 -0
- data/lib/smart_prompt/adapters/zhipu/text.rb +57 -0
- data/lib/smart_prompt/adapters/zhipu/video.rb +101 -0
- data/lib/smart_prompt/adapters/zhipu/voice.rb +55 -0
- data/lib/smart_prompt/concerns/http_client.rb +147 -0
- data/lib/smart_prompt/concerns/image_persistence.rb +62 -0
- data/lib/smart_prompt/concerns/multimodal_messages.rb +108 -0
- data/lib/smart_prompt/concerns/openai_chat_shaping.rb +87 -0
- data/lib/smart_prompt/conversation.rb +26 -2
- data/lib/smart_prompt/engine.rb +5 -0
- data/lib/smart_prompt/history_manager.rb +22 -0
- data/lib/smart_prompt/message.rb +13 -2
- data/lib/smart_prompt/session.rb +47 -16
- data/lib/smart_prompt/siliconflow_adapter.rb +91 -0
- data/lib/smart_prompt/version.rb +1 -1
- data/lib/smart_prompt/worker.rb +67 -5
- data/workers/siliconflow_workers.rb +167 -0
- metadata +22 -2
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SmartPrompt
|
|
6
|
+
# Shared multimodal-message normalization for Net::HTTP adapters (ZhipuAI, SenseNova,
|
|
7
|
+
# SiliconFlow). Turns an OpenAI-style content array into the shape the provider expects,
|
|
8
|
+
# inlining local image/audio/video files as base64 data URLs and passing http(s)/data
|
|
9
|
+
# URLs through. Each adapter previously carried a near-identical copy of this logic.
|
|
10
|
+
#
|
|
11
|
+
# SiliconFlow's variant is the superset (image_url + video_url + audio_url, preserving
|
|
12
|
+
# detail/max_frames/fps); Zhipu/SenseNova only ever send image_url, which is a subset.
|
|
13
|
+
module MultimodalMessages
|
|
14
|
+
SUPPORTED_IMAGE_FORMATS = %w[jpg jpeg png gif bmp webp].freeze
|
|
15
|
+
|
|
16
|
+
def process_multimodal_messages(messages)
|
|
17
|
+
messages.map do |msg|
|
|
18
|
+
role = msg[:role] || msg["role"]
|
|
19
|
+
content = msg[:content] || msg["content"]
|
|
20
|
+
content = content.map { |item| normalize_content_item(item) } if content.is_a?(Array)
|
|
21
|
+
{ "role" => role, "content" => content }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def normalize_content_item(item)
|
|
26
|
+
return { "type" => "text", "text" => item.to_s } unless item.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
case item[:type] || item["type"]
|
|
29
|
+
when "image_url"
|
|
30
|
+
normalize_media_part(item, "image_url", :image)
|
|
31
|
+
when "video_url"
|
|
32
|
+
normalize_media_part(item, "video_url", :video)
|
|
33
|
+
when "audio_url"
|
|
34
|
+
normalize_media_part(item, "audio_url", :audio)
|
|
35
|
+
else
|
|
36
|
+
stringify_hash(item)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Build an image_url/video_url/audio_url part, inlining local files as data URLs and
|
|
41
|
+
# preserving any extra keys (detail, max_frames, fps) on the media hash.
|
|
42
|
+
def normalize_media_part(item, type, media_kind)
|
|
43
|
+
iu = item[type.to_sym] || item[type]
|
|
44
|
+
if iu.is_a?(Hash)
|
|
45
|
+
url = iu[:url] || iu["url"]
|
|
46
|
+
part = { "type" => type, type => { "url" => normalize_media_url(url, media_kind) } }
|
|
47
|
+
iu.each { |k, v| part[type][k.to_s] = stringify_hash(v) unless k.to_s == "url" }
|
|
48
|
+
part
|
|
49
|
+
else
|
|
50
|
+
{ "type" => type, type => { "url" => normalize_media_url(iu, media_kind) } }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve a media URL embedded in a message: http(s)/data pass through; a local path
|
|
55
|
+
# is base64-encoded as a data URL.
|
|
56
|
+
def normalize_media_url(url, kind = :image)
|
|
57
|
+
return url if url.nil?
|
|
58
|
+
return url if url.start_with?("http://", "https://", "data:")
|
|
59
|
+
|
|
60
|
+
label = kind == :image ? "Image" : kind.to_s.capitalize
|
|
61
|
+
raise Error, "#{label} file not found: #{url}" unless File.exist?(url)
|
|
62
|
+
ext = File.extname(url).downcase.delete(".")
|
|
63
|
+
case kind
|
|
64
|
+
when :image
|
|
65
|
+
raise Error, "Unsupported image format: #{ext}" unless SUPPORTED_IMAGE_FORMATS.include?(ext)
|
|
66
|
+
mime = ext == "jpg" ? "jpeg" : ext
|
|
67
|
+
"data:image/#{mime};base64,#{Base64.strict_encode64(File.binread(url))}"
|
|
68
|
+
when :audio
|
|
69
|
+
"data:audio/#{ext.empty? ? 'wav' : ext};base64,#{Base64.strict_encode64(File.binread(url))}"
|
|
70
|
+
when :video
|
|
71
|
+
"data:video/#{ext.empty? ? 'mp4' : ext};base64,#{Base64.strict_encode64(File.binread(url))}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Single-arg image-only shim (call sites like generate_video pass a plain image URL).
|
|
76
|
+
def normalize_image_url(url)
|
|
77
|
+
normalize_media_url(url, :image)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Accept a local path, a base64 data URL, or an http(s) URL for image-edit /
|
|
81
|
+
# image-to-video `image` fields.
|
|
82
|
+
def normalize_input_image(image)
|
|
83
|
+
return image if image.nil?
|
|
84
|
+
|
|
85
|
+
if image.is_a?(String)
|
|
86
|
+
return image if image.start_with?("data:")
|
|
87
|
+
return image if image.start_with?("http://", "https://")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise Error, "Image file not found: #{image}" unless File.exist?(image)
|
|
91
|
+
ext = File.extname(image).downcase.delete(".")
|
|
92
|
+
raise Error, "Unsupported image format: #{ext}" unless SUPPORTED_IMAGE_FORMATS.include?(ext)
|
|
93
|
+
mime = ext == "jpg" ? "jpeg" : ext
|
|
94
|
+
"data:image/#{mime};base64,#{Base64.strict_encode64(File.binread(image))}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stringify_hash(hash)
|
|
98
|
+
case hash
|
|
99
|
+
when Hash
|
|
100
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = stringify_hash(v) }
|
|
101
|
+
when Array
|
|
102
|
+
hash.map { |v| stringify_hash(v) }
|
|
103
|
+
else
|
|
104
|
+
hash
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module SmartPrompt
|
|
2
|
+
# Shared shaping of Net::HTTP chat responses into the OpenAI completion / stream
|
|
3
|
+
# shape that the rest of SmartPrompt (Engine#@stream_proc, Conversation) expects.
|
|
4
|
+
#
|
|
5
|
+
# Reasoning models expose a thinking trace under a provider-specific field —
|
|
6
|
+
# surfaced here uniformly as `reasoning_content`. Adapters override one hook:
|
|
7
|
+
#
|
|
8
|
+
# reasoning_field_name — the source field on message/delta (default
|
|
9
|
+
# "reasoning_content"; SenseNova uses "reasoning"). Its value is remapped to
|
|
10
|
+
# reasoning_content so Engine#@stream_proc needs no per-provider logic.
|
|
11
|
+
#
|
|
12
|
+
# extra_top_level_fields(raw) — extra top-level keys to copy onto the shaped
|
|
13
|
+
# response/chunk (default {}; SenseNova adds system_fingerprint).
|
|
14
|
+
module OpenAIChatShaping
|
|
15
|
+
def build_completion_response(raw)
|
|
16
|
+
msg = raw.dig("choices", 0, "message") || {}
|
|
17
|
+
message = { "role" => msg["role"] || "assistant" }
|
|
18
|
+
message["content"] = msg["content"]
|
|
19
|
+
reasoning = msg[reasoning_field_name]
|
|
20
|
+
message["reasoning_content"] = reasoning if reasoning
|
|
21
|
+
message["tool_calls"] = msg["tool_calls"] if msg["tool_calls"]
|
|
22
|
+
|
|
23
|
+
response = {
|
|
24
|
+
"id" => raw["id"],
|
|
25
|
+
"object" => raw["object"] || "chat.completion",
|
|
26
|
+
"created" => raw["created"],
|
|
27
|
+
"model" => raw["model"],
|
|
28
|
+
"choices" => [{
|
|
29
|
+
"index" => 0,
|
|
30
|
+
"message" => message,
|
|
31
|
+
"finish_reason" => raw.dig("choices", 0, "finish_reason"),
|
|
32
|
+
}],
|
|
33
|
+
}
|
|
34
|
+
response["usage"] = raw["usage"] if raw["usage"]
|
|
35
|
+
merge_extra_top_level(response, raw)
|
|
36
|
+
response
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_stream_chunk(data)
|
|
40
|
+
chunk = {
|
|
41
|
+
"id" => data["id"],
|
|
42
|
+
"object" => data["object"],
|
|
43
|
+
"created" => data["created"],
|
|
44
|
+
"model" => data["model"],
|
|
45
|
+
}
|
|
46
|
+
chunk["usage"] = data["usage"] if data["usage"]
|
|
47
|
+
merge_extra_top_level(chunk, data)
|
|
48
|
+
|
|
49
|
+
choices = data["choices"] || []
|
|
50
|
+
if choices.any?
|
|
51
|
+
delta = choices[0]["delta"] || {}
|
|
52
|
+
new_delta = {}
|
|
53
|
+
new_delta["role"] = delta["role"] if delta["role"]
|
|
54
|
+
new_delta["content"] = delta["content"] if delta["content"]
|
|
55
|
+
reasoning = delta[reasoning_field_name]
|
|
56
|
+
new_delta["reasoning_content"] = reasoning if reasoning
|
|
57
|
+
new_delta["tool_calls"] = delta["tool_calls"] if delta["tool_calls"]
|
|
58
|
+
chunk["choices"] = [{
|
|
59
|
+
"index" => choices[0]["index"] || 0,
|
|
60
|
+
"delta" => new_delta,
|
|
61
|
+
"finish_reason" => choices[0]["finish_reason"],
|
|
62
|
+
}]
|
|
63
|
+
else
|
|
64
|
+
chunk["choices"] = []
|
|
65
|
+
end
|
|
66
|
+
chunk
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ---- hooks (override in adapter) -----------------------------------------
|
|
70
|
+
|
|
71
|
+
def reasoning_field_name
|
|
72
|
+
"reasoning_content"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def extra_top_level_fields(_raw)
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def merge_extra_top_level(target, raw)
|
|
82
|
+
extra_top_level_fields(raw).each do |k, v|
|
|
83
|
+
target[k] = v unless v.nil? || target.key?(k)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -131,8 +131,9 @@ module SmartPrompt
|
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
def sys_msg(message, params = {})
|
|
134
|
-
@sys_msg = thinking_system_message(message)
|
|
135
|
-
|
|
134
|
+
@sys_msg = thinking_system_message(transform_system_message(message))
|
|
135
|
+
upsert_system_message(@sys_msg) if params[:with_history]
|
|
136
|
+
add_message({ role: "system", content: @sys_msg }, false)
|
|
136
137
|
self
|
|
137
138
|
end
|
|
138
139
|
|
|
@@ -171,6 +172,29 @@ module SmartPrompt
|
|
|
171
172
|
"default_#{Time.now.to_i}_#{rand(1000)}"
|
|
172
173
|
end
|
|
173
174
|
|
|
175
|
+
# Apply an optional engine-level hook so embedding applications can inject
|
|
176
|
+
# their own system-prompt policy (e.g. a native tool-call protocol) without
|
|
177
|
+
# monkey-patching WorkerContext.
|
|
178
|
+
def transform_system_message(message)
|
|
179
|
+
transformer = @engine.system_message_transformer if @engine.respond_to?(:system_message_transformer)
|
|
180
|
+
transformer ? transformer.call(message) : message
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Keep exactly one durable system message per session. Repeated rounds
|
|
184
|
+
# replace the persisted system copy instead of appending snapshots of
|
|
185
|
+
# changing progress/repair state forever.
|
|
186
|
+
def upsert_system_message(content)
|
|
187
|
+
if @engine.history_manager
|
|
188
|
+
@use_history_manager = true
|
|
189
|
+
@session_id ||= generate_default_session_id
|
|
190
|
+
@engine.history_manager.upsert_system_message(@session_id, content)
|
|
191
|
+
else
|
|
192
|
+
messages = Array(@engine.history_messages)
|
|
193
|
+
messages.delete_if { |item| (item[:role] || item["role"]).to_s == "system" }
|
|
194
|
+
messages << { role: "system", content: content }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
174
198
|
public
|
|
175
199
|
|
|
176
200
|
def send_msg(params = {})
|
data/lib/smart_prompt/engine.rb
CHANGED
|
@@ -2,6 +2,10 @@ module SmartPrompt
|
|
|
2
2
|
class Engine
|
|
3
3
|
attr_reader :config_file, :config, :adapters, :current_adapter, :llms, :models, :templates
|
|
4
4
|
attr_reader :stream_response, :history_manager
|
|
5
|
+
# Optional callable applied to every system message in Conversation#sys_msg.
|
|
6
|
+
# Lets an embedding application inject its own system-prompt policy (e.g. a
|
|
7
|
+
# native tool-call protocol) without monkey-patching WorkerContext.
|
|
8
|
+
attr_accessor :system_message_transformer
|
|
5
9
|
|
|
6
10
|
def initialize(config_file)
|
|
7
11
|
@config_file = config_file
|
|
@@ -12,6 +16,7 @@ module SmartPrompt
|
|
|
12
16
|
@current_workers = {}
|
|
13
17
|
@history_messages = []
|
|
14
18
|
@history_manager = nil
|
|
19
|
+
@system_message_transformer = nil
|
|
15
20
|
load_config(config_file)
|
|
16
21
|
SmartPrompt.logger.info "Started create the SmartPrompt engine."
|
|
17
22
|
@stream_proc = Proc.new do |chunk, _bytesize|
|
|
@@ -104,6 +104,28 @@ module SmartPrompt
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Keep exactly one durable system message per session. Repeated worker rounds
|
|
108
|
+
# replace the persisted system copy instead of appending snapshots of
|
|
109
|
+
# changing progress/repair state forever. Returns false when the session
|
|
110
|
+
# already held an identical single system message (idempotent no-op).
|
|
111
|
+
def upsert_system_message(session_id, content, options = {})
|
|
112
|
+
begin
|
|
113
|
+
session = get_session(session_id, options)
|
|
114
|
+
systems = session.messages.select(&:system_message?)
|
|
115
|
+
if systems.one? && systems.first.content.to_s == content.to_s
|
|
116
|
+
return false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
session.messages.delete_if(&:system_message?)
|
|
120
|
+
rescue => e
|
|
121
|
+
log_error "Failed to upsert system message in session #{session_id}", e
|
|
122
|
+
raise HistoryManagerError, "Failed to upsert system message: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
add_message(session_id, { role: "system", content: content.to_s }, options)
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
|
|
107
129
|
# Get context (messages) from a session
|
|
108
130
|
def get_context(session_id, max_tokens = nil, strategy = nil)
|
|
109
131
|
begin
|
data/lib/smart_prompt/message.rb
CHANGED
|
@@ -4,7 +4,8 @@ module SmartPrompt
|
|
|
4
4
|
# Message represents a single message in a conversation history
|
|
5
5
|
# It contains role, content, timestamp, and metadata
|
|
6
6
|
class Message
|
|
7
|
-
attr_reader :role, :content, :timestamp, :metadata, :token_count
|
|
7
|
+
attr_reader :role, :content, :timestamp, :metadata, :token_count,
|
|
8
|
+
:tool_calls, :tool_call_id, :reasoning_content
|
|
8
9
|
attr_accessor :importance_score, :is_summary
|
|
9
10
|
|
|
10
11
|
def initialize(data)
|
|
@@ -15,6 +16,12 @@ module SmartPrompt
|
|
|
15
16
|
@token_count = nil # Lazy calculation
|
|
16
17
|
@importance_score = data[:importance_score] || data["importance_score"]
|
|
17
18
|
@is_summary = data[:is_summary] || data["is_summary"] || false
|
|
19
|
+
# Tool-call pairing and reasoning fields must survive serialization so
|
|
20
|
+
# HistoryManager round-trips don't drop tool_calls / tool_call_id /
|
|
21
|
+
# reasoning_content and produce malformed tool requests.
|
|
22
|
+
@tool_calls = data[:tool_calls] || data["tool_calls"]
|
|
23
|
+
@tool_call_id = data[:tool_call_id] || data["tool_call_id"]
|
|
24
|
+
@reasoning_content = data[:reasoning_content] || data["reasoning_content"]
|
|
18
25
|
end
|
|
19
26
|
|
|
20
27
|
# Calculate token count using provided counter
|
|
@@ -29,7 +36,7 @@ module SmartPrompt
|
|
|
29
36
|
|
|
30
37
|
# Convert message to hash format
|
|
31
38
|
def to_h
|
|
32
|
-
{
|
|
39
|
+
h = {
|
|
33
40
|
role: @role,
|
|
34
41
|
content: @content,
|
|
35
42
|
timestamp: @timestamp.iso8601,
|
|
@@ -37,6 +44,10 @@ module SmartPrompt
|
|
|
37
44
|
importance_score: @importance_score,
|
|
38
45
|
is_summary: @is_summary
|
|
39
46
|
}
|
|
47
|
+
h[:tool_calls] = @tool_calls if @tool_calls
|
|
48
|
+
h[:tool_call_id] = @tool_call_id if @tool_call_id
|
|
49
|
+
h[:reasoning_content] = @reasoning_content if @reasoning_content
|
|
50
|
+
h
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
private
|
data/lib/smart_prompt/session.rb
CHANGED
|
@@ -90,20 +90,27 @@ module SmartPrompt
|
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
# Remove oldest non-system messages to meet message count limit
|
|
93
|
+
# Remove oldest non-system messages to meet message count limit, trimming
|
|
94
|
+
# in "pair groups" so an assistant(tool_calls) message is never separated
|
|
95
|
+
# from the tool results that follow it.
|
|
94
96
|
def remove_oldest_messages_to_limit(max_messages)
|
|
95
97
|
system_messages = @messages.select(&:system_message?)
|
|
96
98
|
non_system_messages = @messages.reject(&:system_message?)
|
|
97
99
|
|
|
98
|
-
|
|
99
|
-
messages_to_keep = max_messages - system_messages.length
|
|
100
|
-
messages_to_keep = [messages_to_keep, 0].max
|
|
100
|
+
messages_to_keep = [max_messages - system_messages.length, 0].max
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
kept = []
|
|
103
|
+
count = 0
|
|
104
|
+
pair_groups(non_system_messages).reverse_each do |group|
|
|
105
|
+
break if count + group.length > messages_to_keep
|
|
106
|
+
|
|
107
|
+
kept.unshift(group)
|
|
108
|
+
count += group.length
|
|
109
|
+
end
|
|
110
|
+
@messages = system_messages + kept.flatten
|
|
104
111
|
end
|
|
105
112
|
|
|
106
|
-
# Remove oldest non-system messages to meet token limit
|
|
113
|
+
# Remove oldest non-system messages to meet token limit, also in pair groups.
|
|
107
114
|
def remove_oldest_messages_to_token_limit(max_tokens)
|
|
108
115
|
system_messages = @messages.select(&:system_message?)
|
|
109
116
|
non_system_messages = @messages.reject(&:system_message?)
|
|
@@ -111,21 +118,45 @@ module SmartPrompt
|
|
|
111
118
|
system_tokens = system_messages.sum { |msg| msg.token_count || 0 }
|
|
112
119
|
available_tokens = max_tokens - system_tokens
|
|
113
120
|
|
|
114
|
-
|
|
115
|
-
kept_messages = []
|
|
121
|
+
kept = []
|
|
116
122
|
current_tokens = 0
|
|
123
|
+
pair_groups(non_system_messages).reverse_each do |group|
|
|
124
|
+
group_tokens = group.sum { |msg| msg.token_count || 0 }
|
|
125
|
+
break if current_tokens + group_tokens > available_tokens
|
|
126
|
+
|
|
127
|
+
kept.unshift(group)
|
|
128
|
+
current_tokens += group_tokens
|
|
129
|
+
end
|
|
130
|
+
@messages = system_messages + kept.flatten
|
|
131
|
+
end
|
|
117
132
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
133
|
+
# Split non-system messages into groups where an assistant message that
|
|
134
|
+
# carries tool_calls stays together with the consecutive tool results that
|
|
135
|
+
# follow it. Every other message forms its own single-element group.
|
|
136
|
+
def pair_groups(messages)
|
|
137
|
+
groups = []
|
|
138
|
+
i = 0
|
|
139
|
+
while i < messages.length
|
|
140
|
+
msg = messages[i]
|
|
141
|
+
if assistant_with_tool_calls?(msg)
|
|
142
|
+
group = [msg]
|
|
143
|
+
i += 1
|
|
144
|
+
while i < messages.length && messages[i].role.to_s == "tool"
|
|
145
|
+
group << messages[i]
|
|
146
|
+
i += 1
|
|
147
|
+
end
|
|
148
|
+
groups << group
|
|
123
149
|
else
|
|
124
|
-
|
|
150
|
+
groups << [msg]
|
|
151
|
+
i += 1
|
|
125
152
|
end
|
|
126
153
|
end
|
|
154
|
+
groups
|
|
155
|
+
end
|
|
127
156
|
|
|
128
|
-
|
|
157
|
+
def assistant_with_tool_calls?(msg)
|
|
158
|
+
msg.role.to_s == "assistant" &&
|
|
159
|
+
msg.respond_to?(:tool_calls) && msg.tool_calls && !msg.tool_calls.empty?
|
|
129
160
|
end
|
|
130
161
|
|
|
131
162
|
# Calculate importance score for a message
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "json"
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "concerns/image_persistence"
|
|
7
|
+
require_relative "concerns/openai_chat_shaping"
|
|
8
|
+
require_relative "concerns/multimodal_messages"
|
|
9
|
+
require_relative "concerns/http_client"
|
|
10
|
+
require_relative "adapters/siliconflow/text"
|
|
11
|
+
require_relative "adapters/siliconflow/embed"
|
|
12
|
+
require_relative "adapters/siliconflow/image"
|
|
13
|
+
require_relative "adapters/siliconflow/video"
|
|
14
|
+
require_relative "adapters/siliconflow/voice"
|
|
15
|
+
require_relative "adapters/siliconflow/rerank"
|
|
16
|
+
|
|
17
|
+
module SmartPrompt
|
|
18
|
+
# Adapter for 硅基流动 (SiliconFlow / SiliconCloud) — one adapter owns the whole
|
|
19
|
+
# provider: every category shares the base URL https://api.siliconflow.cn/v1 and
|
|
20
|
+
# Bearer auth.
|
|
21
|
+
#
|
|
22
|
+
# Per-modality behavior lives in capability modules under adapters/siliconflow/
|
|
23
|
+
# (Text / Embed / Image / Video / Voice / Rerank); cross-provider plumbing (HTTP,
|
|
24
|
+
# multimodal normalization, chat shaping, image saving) comes from the shared
|
|
25
|
+
# concerns. This class wires them together + holds config/credentials.
|
|
26
|
+
#
|
|
27
|
+
# Provider-specific quirks (all vs https://docs.siliconflow.cn/cn/api-reference):
|
|
28
|
+
# chat/vision — POST {base}/chat/completions (reasoning_content, no remap)
|
|
29
|
+
# embeddings — POST {base}/embeddings (dimensions only for Qwen3-Embedding)
|
|
30
|
+
# rerank — POST {base}/rerank (results[].relevance_score)
|
|
31
|
+
# image/edit — POST {base}/images/generations (images[].url; image_size/batch_size/guidance_scale)
|
|
32
|
+
# video — POST {base}/video/submit -> POST {base}/video/status (async; results.videos[].url)
|
|
33
|
+
# tts — POST {base}/audio/speech (binary audio response)
|
|
34
|
+
# asr — POST {base}/audio/transcriptions (multipart, field "file")
|
|
35
|
+
# voice — /uploads/audio/voice, /audio/voice/list, /audio/voice/deletions
|
|
36
|
+
class SiliconFlowAdapter < LLMAdapter
|
|
37
|
+
DEFAULT_BASE_URL = "https://api.siliconflow.cn/v1".freeze
|
|
38
|
+
|
|
39
|
+
# Cross-provider shared concerns
|
|
40
|
+
include ImagePersistence
|
|
41
|
+
include OpenAIChatShaping
|
|
42
|
+
include MultimodalMessages
|
|
43
|
+
include HTTPClient
|
|
44
|
+
|
|
45
|
+
# Per-capability modules
|
|
46
|
+
include SiliconFlow::Text
|
|
47
|
+
include SiliconFlow::Embed
|
|
48
|
+
include SiliconFlow::Image
|
|
49
|
+
include SiliconFlow::Video
|
|
50
|
+
include SiliconFlow::Voice
|
|
51
|
+
include SiliconFlow::Rerank
|
|
52
|
+
|
|
53
|
+
# ---- hooks for shared concerns -------------------------------------------
|
|
54
|
+
def provider_label
|
|
55
|
+
"SiliconFlow"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def default_image_prefix
|
|
59
|
+
"siliconflow_image"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(config)
|
|
63
|
+
super
|
|
64
|
+
SmartPrompt.logger.info "Start create the SmartPrompt SiliconFlowAdapter."
|
|
65
|
+
|
|
66
|
+
api_key = @config["api_key"]
|
|
67
|
+
if api_key.is_a?(String) && api_key.start_with?("ENV[") && api_key.end_with?("]")
|
|
68
|
+
api_key = eval(api_key)
|
|
69
|
+
end
|
|
70
|
+
# Tolerate a missing key at construction (e.g. when the ENV var isn't set yet)
|
|
71
|
+
# and let the first request fail with a clear auth error.
|
|
72
|
+
SmartPrompt.logger.warn "SiliconFlow api_key is empty — API calls will fail until it is set." if api_key.nil? || api_key.to_s.strip.empty?
|
|
73
|
+
|
|
74
|
+
@api_key = api_key
|
|
75
|
+
@base_url = (@config["url"] || DEFAULT_BASE_URL).to_s.chomp("/")
|
|
76
|
+
# Optional per-method URL overrides (default to the standard paths off @base_url).
|
|
77
|
+
@image_url = (@config["image_url"] || "#{@base_url}/images/generations").to_s
|
|
78
|
+
@video_submit_url = (@config["video_submit_url"] || "#{@base_url}/video/submit").to_s
|
|
79
|
+
@video_status_url = (@config["video_status_url"] || "#{@base_url}/video/status").to_s
|
|
80
|
+
@speech_url = (@config["speech_url"] || "#{@base_url}/audio/speech").to_s
|
|
81
|
+
@transcription_url = (@config["transcription_url"] || "#{@base_url}/audio/transcriptions").to_s
|
|
82
|
+
@voice_upload_url = (@config["voice_upload_url"] || "#{@base_url}/uploads/audio/voice").to_s
|
|
83
|
+
@voice_list_url = (@config["voice_list_url"] || "#{@base_url}/audio/voice/list").to_s
|
|
84
|
+
@voice_delete_url = (@config["voice_delete_url"] || "#{@base_url}/audio/voice/deletions").to_s
|
|
85
|
+
SmartPrompt.logger.info "SiliconFlow base_url=#{@base_url}"
|
|
86
|
+
rescue => e
|
|
87
|
+
SmartPrompt.logger.error "Failed to initialize SiliconFlow client: #{e.message}"
|
|
88
|
+
raise e.is_a?(SmartPrompt::Error) ? e : LLMAPIError, "Invalid SiliconFlow configuration: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/smart_prompt/version.rb
CHANGED
data/lib/smart_prompt/worker.rb
CHANGED
|
@@ -57,18 +57,42 @@ module SmartPrompt
|
|
|
57
57
|
@params = params
|
|
58
58
|
@engine = engine
|
|
59
59
|
@proc = proc
|
|
60
|
+
@transient_messages = []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Add a user prompt to the current request only, without persisting it to
|
|
64
|
+
# HistoryManager. Progress, retry notes and hard-intervention text are
|
|
65
|
+
# snapshots, not conversation history; persisting one large user message per
|
|
66
|
+
# round would evict the assistant/tool evidence we need to retain.
|
|
67
|
+
def transient_prompt(content)
|
|
68
|
+
@transient_messages << content
|
|
69
|
+
@conversation.prompt(content, with_history: false)
|
|
60
70
|
end
|
|
61
71
|
|
|
62
72
|
def method_missing(method, *args, &block)
|
|
63
73
|
if @conversation.respond_to?(method)
|
|
64
74
|
if method == :send_msg
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
send_params = params
|
|
76
|
+
if params[:with_history] && !@transient_messages.empty?
|
|
77
|
+
# The default send path would send *only* history_messages when
|
|
78
|
+
# with_history=true, silently dropping the transient prompt just
|
|
79
|
+
# added to @conversation.messages. Merge both sources for this
|
|
80
|
+
# request and use the ordinary send path. When there is no transient
|
|
81
|
+
# prompt, keep the original with_history send so the persisted
|
|
82
|
+
# prompt pattern is not duplicated.
|
|
83
|
+
prepare_transient_history_request!
|
|
84
|
+
send_params = params.merge(with_history: false)
|
|
85
|
+
end
|
|
86
|
+
if @proc.nil?
|
|
87
|
+
@conversation.send_msg(send_params)
|
|
67
88
|
else
|
|
68
|
-
@conversation.send_msg_by_stream(
|
|
89
|
+
@conversation.send_msg_by_stream(send_params, &@proc)
|
|
69
90
|
end
|
|
70
91
|
elsif method == :sys_msg
|
|
71
|
-
|
|
92
|
+
# The system message always belongs to the current request. Its
|
|
93
|
+
# durable session copy is upserted by Conversation#sys_msg so a worker
|
|
94
|
+
# loop never accumulates one preserved system message per round.
|
|
95
|
+
@conversation.sys_msg(*args, with_history: params[:with_history])
|
|
72
96
|
elsif method == :prompt
|
|
73
97
|
@conversation.prompt(*args, with_history: params[:with_history])
|
|
74
98
|
else
|
|
@@ -80,7 +104,7 @@ module SmartPrompt
|
|
|
80
104
|
end
|
|
81
105
|
|
|
82
106
|
def respond_to_missing?(method, include_private = false)
|
|
83
|
-
@conversation.respond_to?(method) || super
|
|
107
|
+
method == :transient_prompt || @conversation.respond_to?(method) || super
|
|
84
108
|
end
|
|
85
109
|
|
|
86
110
|
def params
|
|
@@ -107,5 +131,43 @@ module SmartPrompt
|
|
|
107
131
|
worker = Worker.new(worker_name, @engine)
|
|
108
132
|
worker.execute_by_stream(params, proc)
|
|
109
133
|
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# Merge the persisted session history with the transient prompts recorded
|
|
138
|
+
# this round. Persisted prompts stay in history_messages and are therefore
|
|
139
|
+
# already part of the request, so only the transient user messages need to
|
|
140
|
+
# be appended to avoid duplication.
|
|
141
|
+
def prepare_transient_history_request!
|
|
142
|
+
current = Array(@conversation.messages)
|
|
143
|
+
history = session_history
|
|
144
|
+
SmartPrompt.logger&.info(
|
|
145
|
+
"[SmartPrompt history] session=#{@params[:session_id]} " \
|
|
146
|
+
"messages=#{history.size} roles=#{history.map { |message| message_role(message) }.tally}"
|
|
147
|
+
)
|
|
148
|
+
system = current.select { |message| message_role(message) == "system" }
|
|
149
|
+
historical_turns = history.reject { |message| message_role(message) == "system" }
|
|
150
|
+
transient_turn = @transient_messages.map { |content| { role: "user", content: content } }
|
|
151
|
+
@conversation.instance_variable_set(:@messages, system + historical_turns + transient_turn)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def session_history
|
|
155
|
+
if @engine.respond_to?(:history_manager) && @engine.history_manager
|
|
156
|
+
sid = @params[:session_id]
|
|
157
|
+
raise ArgumentError, "history requires an explicit session_id" if sid.to_s.strip.empty?
|
|
158
|
+
|
|
159
|
+
@engine.history_manager.get_context(sid).map(&:to_h)
|
|
160
|
+
elsif @engine.respond_to?(:history_messages)
|
|
161
|
+
Array(@engine.history_messages)
|
|
162
|
+
else
|
|
163
|
+
[]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def message_role(message)
|
|
168
|
+
return message.role.to_s if message.respond_to?(:role)
|
|
169
|
+
|
|
170
|
+
(message[:role] || message["role"]).to_s
|
|
171
|
+
end
|
|
110
172
|
end
|
|
111
173
|
end
|