smart_prompt 0.5.2 → 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.
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module SmartPrompt
2
- VERSION = "0.5.2"
2
+ VERSION = "0.5.3"
3
3
  end
@@ -0,0 +1,167 @@
1
+ # 硅基流动 (SiliconFlow / SiliconCloud) workers for SmartPrompt
2
+ #
3
+ # One worker per model category, reusing the standard DSL (`use`, `model`, `sys_msg`,
4
+ # `prompt`, `send_msg`) and the media helpers. Chat/vision/embed/image/image-edit go
5
+ # through Conversation-delegated methods; rerank/video/tts/asr reach the adapter
6
+ # directly via engine.llms[...] (the methods Conversation does not delegate).
7
+ #
8
+ # `send_msg` transparently becomes streaming when the engine invokes the worker via
9
+ # call_worker_by_stream — so :siliconflow_chat serves both sync and stream callers.
10
+
11
+ # 1. 文本对话 (sync + stream)
12
+ SmartPrompt.define_worker :siliconflow_chat do
13
+ use "sf_chat"
14
+ model params[:model] if params[:model]
15
+ sys_msg(params[:system] || "你是一个有帮助的中文助手,回答简洁准确。", params)
16
+ prompt(params[:prompt] || "你好,请用一句话介绍硅基流动 SiliconFlow。")
17
+ send_msg
18
+ end
19
+
20
+ # 2. 多模态对话 (vision / video / audio). Accepts image_url/video_url/audio_url,
21
+ # a single media url, or arrays (image_urls).
22
+ SmartPrompt.define_worker :siliconflow_vision do
23
+ use "sf_vision"
24
+ model params[:model] if params[:model]
25
+ sys_msg("你是一个专业的多模态分析助手,能够准确描述和分析图像/视频/音频内容。", params)
26
+
27
+ content = [{ type: "text", text: params[:question] || "请描述这张图片中的内容。" }]
28
+ ([params[:image_url]] + (params[:image_urls] || [])).compact.uniq.each do |url|
29
+ content << { type: "image_url", image_url: { url: url } }
30
+ end
31
+ content << { type: "video_url", video_url: { url: params[:video_url] } } if params[:video_url]
32
+ content << { type: "audio_url", audio_url: { url: params[:audio_url] } } if params[:audio_url]
33
+ add_message({ role: "user", content: content })
34
+
35
+ send_msg
36
+ end
37
+
38
+ # 3. 向量模型 (embeddings). Returns a normalized numeric vector of the user text.
39
+ SmartPrompt.define_worker :siliconflow_embed do
40
+ use "sf_embed"
41
+ model params[:model] if params[:model]
42
+ prompt(params[:text] || "硅基流动 SiliconFlow 大模型")
43
+ embeddings(params[:length] || 1024)
44
+ end
45
+
46
+ # 4. 重排 (rerank). Reorders params[:documents] by relevance to params[:query].
47
+ # Conversation does not delegate rerank, so we reach the adapter directly.
48
+ SmartPrompt.define_worker :siliconflow_rerank do
49
+ use "sf_rerank"
50
+ model params[:model] if params[:model]
51
+ adapter = engine.llms["sf_rerank"]
52
+
53
+ adapter.rerank(
54
+ params[:query],
55
+ params[:documents] || [],
56
+ model: params[:model],
57
+ top_n: params[:top_n],
58
+ return_documents: params[:return_documents],
59
+ )
60
+ end
61
+
62
+ # 5. 文生图 (text-to-image). Returns the generated image(s); optionally saves to disk.
63
+ SmartPrompt.define_worker :siliconflow_image do
64
+ use "sf_image"
65
+ model params[:model] if params[:model]
66
+
67
+ images = generate_image(params[:prompt], {
68
+ model: params[:model],
69
+ negative_prompt: params[:negative_prompt],
70
+ image_size: params[:image_size] || params[:size],
71
+ batch_size: params[:batch_size] || params[:n],
72
+ seed: params[:seed],
73
+ num_inference_steps: params[:num_inference_steps],
74
+ guidance_scale: params[:guidance_scale],
75
+ })
76
+
77
+ if params[:save_to_file]
78
+ saved = save_image(images, params[:output_dir] || "./generated_images", params[:filename_prefix] || "siliconflow")
79
+ { images: images, saved_files: saved }
80
+ else
81
+ images
82
+ end
83
+ end
84
+
85
+ # 6. 图像编辑 / 图生图 (Qwen-Image-Edit). Accepts image (and image2/image3 for
86
+ # multi-image fusion) as local path, data URL, or http URL.
87
+ SmartPrompt.define_worker :siliconflow_image_edit do
88
+ use "sf_image"
89
+ model params[:model] || "Qwen/Qwen-Image-Edit-2509"
90
+
91
+ images = edit_image(params[:prompt], {
92
+ model: params[:model] || "Qwen/Qwen-Image-Edit-2509",
93
+ image: params[:image] || params[:image_file],
94
+ image2: params[:image2],
95
+ image3: params[:image3],
96
+ negative_prompt: params[:negative_prompt],
97
+ seed: params[:seed],
98
+ guidance_scale: params[:guidance_scale],
99
+ })
100
+
101
+ if params[:save_to_file]
102
+ saved = save_image(images, params[:output_dir] || "./edited_images", params[:filename_prefix] || "siliconflow_edit")
103
+ { images: images, saved_files: saved }
104
+ else
105
+ images
106
+ end
107
+ end
108
+
109
+ # 7. 文生视频 / 图生视频 (async: submit -> poll -> download).
110
+ SmartPrompt.define_worker :siliconflow_video do
111
+ use "sf_video"
112
+ model params[:model] if params[:model]
113
+ adapter = engine.llms["sf_video"]
114
+
115
+ submitted = adapter.generate_video(params[:prompt], params)
116
+ result = { submitted: submitted }
117
+
118
+ if params[:wait_for_completion]
119
+ completed = adapter.wait_for_video_completion(
120
+ submitted[:request_id],
121
+ check_interval: params[:check_interval] || 10,
122
+ timeout: params[:timeout] || 600
123
+ )
124
+ if completed[:video_url] && params[:download_to_file]
125
+ output_dir = params[:output_dir] || "./generated_videos"
126
+ prefix = params[:filename_prefix] || "siliconflow_video"
127
+ output_path = File.join(output_dir, "#{prefix}_#{submitted[:request_id]}.mp4")
128
+ downloaded = adapter.download_video(completed[:video_url], output_path)
129
+ result = { submitted: submitted, video: completed, downloaded_file: downloaded }
130
+ else
131
+ result = { submitted: submitted, video: completed }
132
+ end
133
+ end
134
+ result
135
+ end
136
+
137
+ # 8. 语音合成 (TTS — CosyVoice2 / MOSS-TTSD). Saves the synthesized audio to disk.
138
+ SmartPrompt.define_worker :siliconflow_tts do
139
+ use "sf_tts"
140
+ model params[:model] if params[:model]
141
+ adapter = engine.llms["sf_tts"]
142
+
143
+ output_path = params[:output_path] || "./generated_audio/siliconflow_tts.mp3"
144
+ info = adapter.synthesize_to_file(
145
+ params[:text],
146
+ output_path,
147
+ voice: params[:voice],
148
+ model: params[:model],
149
+ response_format: params[:response_format] || "mp3",
150
+ speed: params[:speed],
151
+ language: params[:language],
152
+ )
153
+ info
154
+ end
155
+
156
+ # 9. 语音识别 (ASR — SenseVoiceSmall). Transcribes a local audio file.
157
+ SmartPrompt.define_worker :siliconflow_asr do
158
+ use "sf_asr"
159
+ model params[:model] if params[:model]
160
+ adapter = engine.llms["sf_asr"]
161
+
162
+ adapter.transcribe_audio(
163
+ params[:audio_file],
164
+ model: params[:model],
165
+ language: params[:language],
166
+ )
167
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_prompt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei
@@ -152,6 +152,7 @@ files:
152
152
  - config/image_generation_config.yml
153
153
  - config/multimodal_config.yml
154
154
  - config/sensenova_config.yml
155
+ - config/siliconflow_config.yml
155
156
  - config/zhipu_config.yml
156
157
  - docs/ANTHROPIC_EXAMPLES.md
157
158
  - docs/CONVERSATION_INTEGRATION_SUMMARY.md
@@ -176,14 +177,31 @@ files:
176
177
  - examples/multimodal_example.rb
177
178
  - examples/relevance_based_strategy_example.rb
178
179
  - examples/sensenova_example.rb
180
+ - examples/siliconflow_example.rb
179
181
  - examples/stt_example.rb
180
182
  - examples/tts_example.rb
181
183
  - examples/video_generation_example.rb
182
184
  - examples/zhipu_example.rb
183
185
  - lib/smart_prompt.rb
186
+ - lib/smart_prompt/adapters/siliconflow/embed.rb
187
+ - lib/smart_prompt/adapters/siliconflow/image.rb
188
+ - lib/smart_prompt/adapters/siliconflow/rerank.rb
189
+ - lib/smart_prompt/adapters/siliconflow/text.rb
190
+ - lib/smart_prompt/adapters/siliconflow/video.rb
191
+ - lib/smart_prompt/adapters/siliconflow/voice.rb
192
+ - lib/smart_prompt/adapters/zhipu/embed.rb
193
+ - lib/smart_prompt/adapters/zhipu/image.rb
194
+ - lib/smart_prompt/adapters/zhipu/rerank.rb
195
+ - lib/smart_prompt/adapters/zhipu/text.rb
196
+ - lib/smart_prompt/adapters/zhipu/video.rb
197
+ - lib/smart_prompt/adapters/zhipu/voice.rb
184
198
  - lib/smart_prompt/anthropic_adapter.rb
185
199
  - lib/smart_prompt/api_handler.rb
186
200
  - lib/smart_prompt/compression_engine.rb
201
+ - lib/smart_prompt/concerns/http_client.rb
202
+ - lib/smart_prompt/concerns/image_persistence.rb
203
+ - lib/smart_prompt/concerns/multimodal_messages.rb
204
+ - lib/smart_prompt/concerns/openai_chat_shaping.rb
187
205
  - lib/smart_prompt/context_strategy.rb
188
206
  - lib/smart_prompt/conversation.rb
189
207
  - lib/smart_prompt/db_adapter.rb
@@ -202,6 +220,7 @@ files:
202
220
  - lib/smart_prompt/relevance_based_strategy.rb
203
221
  - lib/smart_prompt/sensenova_adapter.rb
204
222
  - lib/smart_prompt/session.rb
223
+ - lib/smart_prompt/siliconflow_adapter.rb
205
224
  - lib/smart_prompt/sliding_window_strategy.rb
206
225
  - lib/smart_prompt/stt_adapter.rb
207
226
  - lib/smart_prompt/summary_based_strategy.rb
@@ -216,6 +235,7 @@ files:
216
235
  - workers/image_generation_workers.rb
217
236
  - workers/multimodal_workers.rb
218
237
  - workers/sensenova_workers.rb
238
+ - workers/siliconflow_workers.rb
219
239
  - workers/stt_workers.rb
220
240
  - workers/tts_workers.rb
221
241
  - workers/video_generation_workers.rb