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.
@@ -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
- # Save one or many generated images to disk (Array from #generate_image or a single hash).
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
- # Pass messages through, normalizing any multimodal content. Local image paths inside
196
- # image_url.url are converted to data: URLs; http(s)/data URLs and plain text pass through.
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
- raise Error, "Image file not found: #{url}" unless File.exist?(url)
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
- def save_single_image(image_data, output_dir, filename)
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
@@ -1,3 +1,3 @@
1
1
  module SmartPrompt
2
- VERSION = "0.5.1"
2
+ VERSION = "0.5.3"
3
3
  end