smart_prompt 0.5.1 → 0.5.3
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 +7 -0
- data/README.cn.md +1 -0
- data/README.md +1 -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/sensenova_adapter.rb +34 -211
- data/lib/smart_prompt/siliconflow_adapter.rb +91 -0
- data/lib/smart_prompt/version.rb +1 -1
- data/lib/smart_prompt/zhipu_adapter.rb +51 -575
- data/lib/smart_prompt.rb +1 -0
- data/workers/siliconflow_workers.rb +167 -0
- metadata +21 -1
|
@@ -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
|
|
@@ -3,6 +3,10 @@ require "json"
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "uri"
|
|
5
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"
|
|
6
10
|
|
|
7
11
|
module SmartPrompt
|
|
8
12
|
# Adapter for SenseNova (商汤 日日新) — the SenseCore large-model platform.
|
|
@@ -38,7 +42,6 @@ module SmartPrompt
|
|
|
38
42
|
2048x2048 2752x1536 1536x2752 3072x1376 1344x3136 2560x720 3072x864
|
|
39
43
|
].freeze
|
|
40
44
|
DEFAULT_IMAGE_SIZE = "2048x2048".freeze
|
|
41
|
-
SUPPORTED_IMAGE_FORMATS = %w[jpg jpeg png gif bmp webp].freeze
|
|
42
45
|
|
|
43
46
|
# SenseNova sampling parameters forwarded from config to the chat request when present.
|
|
44
47
|
CHAT_OPTIONAL_KEYS = %w[
|
|
@@ -46,6 +49,31 @@ module SmartPrompt
|
|
|
46
49
|
reasoning_effort max_completion_tokens max_tokens
|
|
47
50
|
].freeze
|
|
48
51
|
|
|
52
|
+
include ImagePersistence
|
|
53
|
+
include OpenAIChatShaping
|
|
54
|
+
include MultimodalMessages
|
|
55
|
+
include HTTPClient
|
|
56
|
+
|
|
57
|
+
# ---- hooks for shared concerns -------------------------------------------
|
|
58
|
+
def provider_label
|
|
59
|
+
"SenseNova"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def default_image_prefix
|
|
63
|
+
"sensenova_image"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# SenseNova exposes the reasoning trace under `reasoning` (not reasoning_content)
|
|
67
|
+
# and also returns system_fingerprint — override the OpenAIChatShaping hooks so the
|
|
68
|
+
# shared shaper still produces the uniform reasoning_content / fingerprint output.
|
|
69
|
+
def reasoning_field_name
|
|
70
|
+
"reasoning"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extra_top_level_fields(raw)
|
|
74
|
+
{ "system_fingerprint" => raw["system_fingerprint"] }
|
|
75
|
+
end
|
|
76
|
+
|
|
49
77
|
def initialize(config)
|
|
50
78
|
super
|
|
51
79
|
SmartPrompt.logger.info "Start create the SmartPrompt SenseNovaAdapter."
|
|
@@ -166,16 +194,7 @@ module SmartPrompt
|
|
|
166
194
|
images
|
|
167
195
|
end
|
|
168
196
|
|
|
169
|
-
#
|
|
170
|
-
def save_image(image_data, output_dir = "./output", filename_prefix = "sensenova_image")
|
|
171
|
-
FileUtils.mkdir_p(output_dir)
|
|
172
|
-
images = image_data.is_a?(Array) ? image_data : [image_data]
|
|
173
|
-
saved = images.each_with_index.map do |img, index|
|
|
174
|
-
save_single_image(img, output_dir, "#{filename_prefix}_#{index + 1}")
|
|
175
|
-
end
|
|
176
|
-
SmartPrompt.logger.info "Saved #{saved.size} SenseNova image(s) to #{output_dir}"
|
|
177
|
-
saved
|
|
178
|
-
end
|
|
197
|
+
# (save_image / save_single_image provided by ImagePersistence concern.)
|
|
179
198
|
|
|
180
199
|
private
|
|
181
200
|
|
|
@@ -192,171 +211,10 @@ module SmartPrompt
|
|
|
192
211
|
body
|
|
193
212
|
end
|
|
194
213
|
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
def process_multimodal_messages(messages)
|
|
198
|
-
messages.map do |msg|
|
|
199
|
-
role = msg[:role] || msg["role"]
|
|
200
|
-
content = msg[:content] || msg["content"]
|
|
201
|
-
content = content.map { |item| normalize_content_item(item) } if content.is_a?(Array)
|
|
202
|
-
{ "role" => role, "content" => content }
|
|
203
|
-
end
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def normalize_content_item(item)
|
|
207
|
-
return { "type" => "text", "text" => item.to_s } unless item.is_a?(Hash)
|
|
208
|
-
|
|
209
|
-
type = item[:type] || item["type"]
|
|
210
|
-
if type == "image_url"
|
|
211
|
-
iu = item[:image_url] || item["image_url"]
|
|
212
|
-
url = iu.is_a?(Hash) ? (iu[:url] || iu["url"]) : iu
|
|
213
|
-
{ "type" => "image_url", "image_url" => { "url" => normalize_image_url(url) } }
|
|
214
|
-
else
|
|
215
|
-
stringify_hash(item)
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def normalize_image_url(url)
|
|
220
|
-
return url if url.nil?
|
|
221
|
-
return url if url.start_with?("http://", "https://", "data:")
|
|
214
|
+
# (process_multimodal_messages / normalize_* / stringify_hash provided by MultimodalMessages concern.)
|
|
215
|
+
# (build_completion_response / build_stream_chunk provided by OpenAIChatShaping concern.)
|
|
222
216
|
|
|
223
|
-
|
|
224
|
-
ext = File.extname(url).downcase.delete(".")
|
|
225
|
-
raise Error, "Unsupported image format: #{ext}" unless SUPPORTED_IMAGE_FORMATS.include?(ext)
|
|
226
|
-
mime = ext == "jpg" ? "jpeg" : ext
|
|
227
|
-
"data:image/#{mime};base64,#{Base64.strict_encode64(File.binread(url))}"
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
# ---- response shaping -----------------------------------------------------
|
|
231
|
-
|
|
232
|
-
# Convert a non-streaming SenseNova response into the OpenAI completion shape the
|
|
233
|
-
# rest of SmartPrompt expects, surfacing the reasoning model's `reasoning` field.
|
|
234
|
-
def build_completion_response(raw)
|
|
235
|
-
msg = raw.dig("choices", 0, "message") || {}
|
|
236
|
-
message = { "role" => msg["role"] || "assistant" }
|
|
237
|
-
message["content"] = msg["content"]
|
|
238
|
-
message["reasoning_content"] = msg["reasoning"] if msg["reasoning"]
|
|
239
|
-
message["tool_calls"] = msg["tool_calls"] if msg["tool_calls"]
|
|
240
|
-
|
|
241
|
-
response = {
|
|
242
|
-
"id" => raw["id"],
|
|
243
|
-
"object" => raw["object"] || "chat.completion",
|
|
244
|
-
"created" => raw["created"],
|
|
245
|
-
"model" => raw["model"],
|
|
246
|
-
"choices" => [{
|
|
247
|
-
"index" => 0,
|
|
248
|
-
"message" => message,
|
|
249
|
-
"finish_reason" => raw.dig("choices", 0, "finish_reason"),
|
|
250
|
-
}],
|
|
251
|
-
}
|
|
252
|
-
response["usage"] = raw["usage"] if raw["usage"]
|
|
253
|
-
response["system_fingerprint"] = raw["system_fingerprint"] if raw["system_fingerprint"]
|
|
254
|
-
response
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
# Convert one SSE event from SenseNova's stream into an OpenAI-style streaming chunk.
|
|
258
|
-
# The key remap is delta.reasoning -> delta.reasoning_content, which is what
|
|
259
|
-
# Engine#@stream_proc reads for reasoning models.
|
|
260
|
-
def build_stream_chunk(data)
|
|
261
|
-
chunk = {
|
|
262
|
-
"id" => data["id"],
|
|
263
|
-
"object" => data["object"],
|
|
264
|
-
"created" => data["created"],
|
|
265
|
-
"model" => data["model"],
|
|
266
|
-
}
|
|
267
|
-
chunk["usage"] = data["usage"] if data["usage"]
|
|
268
|
-
chunk["system_fingerprint"] = data["system_fingerprint"] if data["system_fingerprint"]
|
|
269
|
-
|
|
270
|
-
choices = data["choices"] || []
|
|
271
|
-
if choices.any?
|
|
272
|
-
delta = choices[0]["delta"] || {}
|
|
273
|
-
new_delta = {}
|
|
274
|
-
new_delta["role"] = delta["role"] if delta["role"]
|
|
275
|
-
new_delta["content"] = delta["content"] if delta["content"]
|
|
276
|
-
new_delta["reasoning_content"] = delta["reasoning"] if delta["reasoning"]
|
|
277
|
-
new_delta["tool_calls"] = delta["tool_calls"] if delta["tool_calls"]
|
|
278
|
-
chunk["choices"] = [{
|
|
279
|
-
"index" => choices[0]["index"] || 0,
|
|
280
|
-
"delta" => new_delta,
|
|
281
|
-
"finish_reason" => choices[0]["finish_reason"],
|
|
282
|
-
}]
|
|
283
|
-
else
|
|
284
|
-
# Usage-only final event (choices is an empty array).
|
|
285
|
-
chunk["choices"] = []
|
|
286
|
-
end
|
|
287
|
-
chunk
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
# ---- HTTP -----------------------------------------------------------------
|
|
291
|
-
|
|
292
|
-
def http_post_json(url, body)
|
|
293
|
-
uri = URI.parse(url)
|
|
294
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
295
|
-
http.use_ssl = (uri.scheme == "https")
|
|
296
|
-
http.open_timeout = 30
|
|
297
|
-
http.read_timeout = 240
|
|
298
|
-
|
|
299
|
-
request = Net::HTTP::Post.new(uri.request_uri)
|
|
300
|
-
request["Content-Type"] = "application/json"
|
|
301
|
-
request["Authorization"] = "Bearer #{@api_key}"
|
|
302
|
-
request.body = body.to_json
|
|
303
|
-
|
|
304
|
-
SmartPrompt.logger.debug "SenseNova POST #{uri} body=#{body.to_json}"
|
|
305
|
-
response = http.request(request)
|
|
306
|
-
|
|
307
|
-
if response.is_a?(Net::HTTPSuccess)
|
|
308
|
-
JSON.parse(response.body)
|
|
309
|
-
else
|
|
310
|
-
SmartPrompt.logger.error "SenseNova API error: #{response.code} - #{response.body}"
|
|
311
|
-
raise LLMAPIError, "SenseNova API error: #{response.code} - #{response.body}"
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
# POST with stream:true and yield each parsed SSE `data:` payload to the block.
|
|
316
|
-
def stream_chat(url, body)
|
|
317
|
-
uri = URI.parse(url)
|
|
318
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
319
|
-
http.use_ssl = (uri.scheme == "https")
|
|
320
|
-
http.open_timeout = 30
|
|
321
|
-
http.read_timeout = 300
|
|
322
|
-
|
|
323
|
-
request = Net::HTTP::Post.new(uri.request_uri)
|
|
324
|
-
request["Content-Type"] = "application/json"
|
|
325
|
-
request["Authorization"] = "Bearer #{@api_key}"
|
|
326
|
-
request["Accept"] = "text/event-stream"
|
|
327
|
-
request.body = body.to_json
|
|
328
|
-
|
|
329
|
-
buffer = ""
|
|
330
|
-
done = false
|
|
331
|
-
|
|
332
|
-
http.request(request) do |response|
|
|
333
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
334
|
-
raise LLMAPIError, "SenseNova stream error: #{response.code} - #{response.body}"
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
response.read_body do |segment|
|
|
338
|
-
break if done
|
|
339
|
-
buffer << segment
|
|
340
|
-
while (idx = buffer.index("\n"))
|
|
341
|
-
line = buffer.slice!(0, idx + 1).strip
|
|
342
|
-
next if line.empty? || !line.start_with?("data:")
|
|
343
|
-
|
|
344
|
-
payload = line.sub(/\Adata:\s*/, "")
|
|
345
|
-
if payload == "[DONE]"
|
|
346
|
-
done = true
|
|
347
|
-
break
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
begin
|
|
351
|
-
data = JSON.parse(payload)
|
|
352
|
-
rescue JSON::ParserError
|
|
353
|
-
next
|
|
354
|
-
end
|
|
355
|
-
yield data
|
|
356
|
-
end
|
|
357
|
-
end
|
|
358
|
-
end
|
|
359
|
-
end
|
|
217
|
+
# (http_post_json / stream_chat provided by HTTPClient concern.)
|
|
360
218
|
|
|
361
219
|
# Resolve the image size: default to 2048x2048 when none given, and warn (but still
|
|
362
220
|
# send) when the caller asks for a size sensenova-u1-fast does not accept.
|
|
@@ -370,41 +228,6 @@ module SmartPrompt
|
|
|
370
228
|
size
|
|
371
229
|
end
|
|
372
230
|
|
|
373
|
-
|
|
374
|
-
if image_data[:b64_json]
|
|
375
|
-
file_path = File.join(output_dir, "#{filename}.png")
|
|
376
|
-
File.binwrite(file_path, Base64.decode64(image_data[:b64_json]))
|
|
377
|
-
elsif image_data[:url]
|
|
378
|
-
uri = URI.parse(image_data[:url])
|
|
379
|
-
response = Net::HTTP.get_response(uri)
|
|
380
|
-
raise Error, "Failed to download image from URL: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
381
|
-
|
|
382
|
-
ext = case response["content-type"]
|
|
383
|
-
when "image/jpeg", "image/jpg" then "jpg"
|
|
384
|
-
when "image/png" then "png"
|
|
385
|
-
when "image/gif" then "gif"
|
|
386
|
-
when "image/webp" then "webp"
|
|
387
|
-
else "png"
|
|
388
|
-
end
|
|
389
|
-
file_path = File.join(output_dir, "#{filename}.#{ext}")
|
|
390
|
-
File.binwrite(file_path, response.body)
|
|
391
|
-
else
|
|
392
|
-
raise Error, "No image data available to save"
|
|
393
|
-
end
|
|
394
|
-
file_path
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
def stringify_hash(hash)
|
|
398
|
-
case hash
|
|
399
|
-
when Hash
|
|
400
|
-
hash.each_with_object({}) do |(k, v), memo|
|
|
401
|
-
memo[k.to_s] = stringify_hash(v)
|
|
402
|
-
end
|
|
403
|
-
when Array
|
|
404
|
-
hash.map { |v| stringify_hash(v) }
|
|
405
|
-
else
|
|
406
|
-
hash
|
|
407
|
-
end
|
|
408
|
-
end
|
|
231
|
+
# (stringify_hash provided by MultimodalMessages concern.)
|
|
409
232
|
end
|
|
410
233
|
end
|
|
@@ -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