Class: SmartPrompt::SenseNovaAdapter
- Inherits:
-
LLMAdapter
- Object
- LLMAdapter
- SmartPrompt::SenseNovaAdapter
- Defined in:
- lib/smart_prompt/sensenova_adapter.rb
Overview
Adapter for SenseNova (商汤 日日新) — the SenseCore large-model platform.
One adapter owns the whole provider: all four documented model categories share the same ‘api.sensenova.cn` domain and Bearer-token auth, so a single config block serves them just by changing `model`.
1. 商量 文本对话 / 多模态 (chat + vision) — OpenAI-compatible
POST {url}/chat/completions (url defaults to .../compatible-mode/v2)
Streaming is SSE; the model may emit a `reasoning`/`delta.reasoning` field on
reasoning models, which we remap to OpenAI's `reasoning_content` so the engine's
stream aggregator (Engine#@stream_proc) keeps working unchanged.
2. Cupido 向量模型 (embeddings) — native, non-OpenAI response shape
POST {embeddings_url} (defaults to .../v1/llm/embeddings)
Body {model, input:[...]}; response {embeddings:[{index, embedding, ...}]}.
3. 秒画 文生图 (text-to-image) — OpenAI-compatible /images/generations
POST {image_url} (native /v1 base, e.g. .../v1/images/generations;
NOT under compatible-mode/v2, which 404s)
We talk to the endpoints directly with Net::HTTP (like the image/tts/stt adapters) rather than the ‘openai` gem, because we must surface SenseNova’s ‘reasoning` field, remap streaming deltas, and handle the native embeddings shape. No new gem deps.
Constant Summary collapse
- DEFAULT_BASE_URL =
"https://api.sensenova.cn/compatible-mode/v2".freeze
- DEFAULT_EMBEDDINGS_URL =
"https://api.sensenova.cn/v1/llm/embeddings".freeze
- DEFAULT_IMAGE_URL =
秒画 text-to-image (sensenova-u1-fast) lives on the token.sensenova.cn /v1 base (confirmed working 2026-06-19). NOT under compatible-mode/v2, which 404s.
"https://token.sensenova.cn/v1/images/generations".freeze
- VALID_IMAGE_SIZES =
Sizes accepted by sensenova-u1-fast (the API 400s on anything else, e.g. 1024x1024).
%w[ 1664x2496 2496x1664 1760x2368 2368x1760 1824x2272 2272x1824 2048x2048 2752x1536 1536x2752 3072x1376 1344x3136 2560x720 3072x864 ].freeze
- DEFAULT_IMAGE_SIZE =
"2048x2048".freeze
- SUPPORTED_IMAGE_FORMATS =
%w[jpg jpeg png gif bmp webp].freeze
- CHAT_OPTIONAL_KEYS =
SenseNova sampling parameters forwarded from config to the chat request when present.
%w[ top_p top_k min_p presence_penalty frequency_penalty repetition_penalty reasoning_effort max_completion_tokens max_tokens ].freeze
Instance Attribute Summary
Attributes inherited from LLMAdapter
Instance Method Summary collapse
-
#embeddings(text, model) ⇒ Object
Cupido embeddings.
-
#generate_image(prompt, params = {}) ⇒ Object
秒画 text-to-image via the OpenAI-compatible /images/generations endpoint.
-
#initialize(config) ⇒ SenseNovaAdapter
constructor
A new instance of SenseNovaAdapter.
-
#save_image(image_data, output_dir = "./output", filename_prefix = "sensenova_image") ⇒ Object
Save one or many generated images to disk (Array from #generate_image or a single hash).
-
#send_request(messages, model = nil, temperature = nil, tools = nil, proc = nil) ⇒ Object
Chat / multimodal request.
Constructor Details
#initialize(config) ⇒ SenseNovaAdapter
Returns a new instance of SenseNovaAdapter.
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 49 def initialize(config) super SmartPrompt.logger.info "Start create the SmartPrompt SenseNovaAdapter." api_key = @config["api_key"] if api_key.is_a?(String) && api_key.start_with?("ENV[") && api_key.end_with?("]") api_key = eval(api_key) end # Match the other adapters: tolerate a missing key at construction (e.g. when the # ENV var isn't set yet) and let the first request fail with a clear auth error. SmartPrompt.logger.warn "SenseNova api_key is empty — API calls will fail until it is set." if api_key.nil? || api_key.to_s.strip.empty? @api_key = api_key @base_url = (@config["url"] || DEFAULT_BASE_URL).to_s.chomp("/") @embeddings_url = (@config["embeddings_url"] || DEFAULT_EMBEDDINGS_URL).to_s # 秒画 image generation lives on the native /v1 base (NOT compatible-mode/v2), # e.g. https://api.sensenova.cn/v1/images/generations. Override per-llm if needed. @image_url = (@config["image_url"] || DEFAULT_IMAGE_URL).to_s SmartPrompt.logger.info "SenseNova base_url=#{@base_url}" rescue => e SmartPrompt.logger.error "Failed to initialize SenseNova client: #{e.}" raise e.is_a?(SmartPrompt::Error) ? e : LLMAPIError, "Invalid SenseNova configuration: #{e.}" end |
Instance Method Details
#embeddings(text, model) ⇒ Object
Cupido embeddings. SenseNova’s native endpoint takes input: and returns embedding:, …]}; we surface the first vector.
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 105 def (text, model) model_name = model || @config["embedding_model"] || @config["model"] SmartPrompt.logger.info "SenseNovaAdapter: embeddings model=#{model_name}" body = { "model" => model_name, "input" => [text.to_s] } response = begin http_post_json(@embeddings_url, body) rescue LLMAPIError, Error raise rescue => e raise LLMAPIError, "Failed to call SenseNova embeddings: #{e.}" end items = response["embeddings"] || response["data"] unless items.is_a?(Array) && items.any? && items[0]["embedding"] raise LLMAPIError, "No embedding vector in SenseNova response: #{response.inspect}" end items[0]["embedding"] end |
#generate_image(prompt, params = {}) ⇒ Object
秒画 text-to-image via the OpenAI-compatible /images/generations endpoint. Response is parsed defensively (OpenAI ‘data[]` or SenseNova `images[]`). Returns an Array of b64_json:, seed:.
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 129 def generate_image(prompt, params = {}) SmartPrompt.logger.info "SenseNovaAdapter: generating image" raise Error, "Prompt cannot be empty" if prompt.nil? || prompt.to_s.strip.empty? model_name = params[:model] || @config["image_model"] || @config["model"] raise Error, "No model configured for image generation" if model_name.nil? || model_name.to_s.strip.empty? body = { "model" => model_name, "prompt" => prompt.to_s } body["n"] = params[:n] if params[:n] body["size"] = resolve_image_size(params[:size] || params[:image_size]) body["response_format"] = params[:response_format] if params[:response_format] body["negative_prompt"] = params[:negative_prompt] if params[:negative_prompt] body["seed"] = params[:seed] if params[:seed] body["num_inference_steps"] = params[:num_inference_steps] if params[:num_inference_steps] body["guidance_scale"] = params[:guidance_scale] if params[:guidance_scale] SmartPrompt.logger.info "SenseNova image params: #{body.except('prompt').inspect}" response = begin http_post_json(@image_url, body) rescue LLMAPIError, Error raise rescue => e raise Error, "Failed to call SenseNova image generation: #{e.}" end items = response["data"] || response["images"] unless items.is_a?(Array) && items.any? SmartPrompt.logger.error "SenseNova image response had no data: #{response.inspect}" raise LLMAPIError, "No image data in SenseNova response" end images = items.map do |d| { url: d["url"], b64_json: d["b64_json"], seed: d["seed"] } end SmartPrompt.logger.info "SenseNovaAdapter: generated #{images.size} image(s)" images end |
#save_image(image_data, output_dir = "./output", filename_prefix = "sensenova_image") ⇒ Object
Save one or many generated images to disk (Array from #generate_image or a single hash).
170 171 172 173 174 175 176 177 178 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 170 def save_image(image_data, output_dir = "./output", filename_prefix = "sensenova_image") FileUtils.mkdir_p(output_dir) images = image_data.is_a?(Array) ? image_data : [image_data] saved = images.each_with_index.map do |img, index| save_single_image(img, output_dir, "#{filename_prefix}_#{index + 1}") end SmartPrompt.logger.info "Saved #{saved.size} SenseNova image(s) to #{output_dir}" saved end |
#send_request(messages, model = nil, temperature = nil, tools = nil, proc = nil) ⇒ Object
Chat / multimodal request.
Non-streaming returns a full OpenAI-format hash (so last_response carries usage + reasoning); streaming calls proc with each OpenAI-shaped chunk and returns nil.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 79 def send_request(, model = nil, temperature = nil, tools = nil, proc = nil) model_name = model || @config["model"] body = build_chat_body(, model_name, temperature, tools) SmartPrompt.logger.info "SenseNovaAdapter: chat request model=#{model_name} stream=#{!proc.nil?}" if proc body["stream"] = true stream_chat("#{@base_url}/chat/completions", body) { |data| proc.call(build_stream_chunk(data), 0) } SmartPrompt.logger.info "SenseNovaAdapter: streaming request finished" nil else raw = http_post_json("#{@base_url}/chat/completions", body) response = build_completion_response(raw) @last_response = response SmartPrompt.logger.info "SenseNovaAdapter: received chat response" response end rescue LLMAPIError, Error raise rescue => e SmartPrompt.logger.error "SenseNova chat error: #{e.}" raise LLMAPIError, "Failed to call SenseNova chat: #{e.}" end |