Class: SmartPrompt::SenseNovaAdapter
- Inherits:
-
LLMAdapter
- Object
- LLMAdapter
- SmartPrompt::SenseNovaAdapter
- Includes:
- HTTPClient, ImagePersistence, MultimodalMessages, OpenAIChatShaping
- 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
- 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
- #default_image_prefix ⇒ Object
-
#embeddings(text, model) ⇒ Object
Cupido embeddings.
- #extra_top_level_fields(raw) ⇒ Object
-
#generate_image(prompt, params = {}) ⇒ Object
秒画 text-to-image via the OpenAI-compatible /images/generations endpoint.
-
#initialize(config) ⇒ SenseNovaAdapter
constructor
A new instance of SenseNovaAdapter.
-
#provider_label ⇒ Object
—- hooks for shared concerns ——————————————-.
-
#reasoning_field_name ⇒ Object
SenseNova exposes the reasoning trace under ‘reasoning` (not reasoning_content) and also returns system_fingerprint — override the OpenAIChatShaping hooks so the shared shaper still produces the uniform reasoning_content / fingerprint output.
-
#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.
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 77 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
#default_image_prefix ⇒ Object
62 63 64 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 62 def default_image_prefix "sensenova_image" end |
#embeddings(text, model) ⇒ Object
Cupido embeddings. SenseNova’s native endpoint takes input: and returns embedding:, …]}; we surface the first vector.
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 133 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 |
#extra_top_level_fields(raw) ⇒ Object
73 74 75 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 73 def extra_top_level_fields(raw) { "system_fingerprint" => raw["system_fingerprint"] } 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:.
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 157 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 |
#provider_label ⇒ Object
—- hooks for shared concerns ——————————————-
58 59 60 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 58 def provider_label "SenseNova" end |
#reasoning_field_name ⇒ Object
SenseNova exposes the reasoning trace under ‘reasoning` (not reasoning_content) and also returns system_fingerprint — override the OpenAIChatShaping hooks so the shared shaper still produces the uniform reasoning_content / fingerprint output.
69 70 71 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 69 def reasoning_field_name "reasoning" 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.
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/smart_prompt/sensenova_adapter.rb', line 107 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 |