Class: SmartPrompt::SenseNovaAdapter

Inherits:
LLMAdapter show all
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

#last_response

Instance Method Summary collapse

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.message}"
  raise e.is_a?(SmartPrompt::Error) ? e : LLMAPIError, "Invalid SenseNova configuration: #{e.message}"
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 embeddings(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.message}"
    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:.

Raises:



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.message}"
    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(messages, model = nil, temperature = nil, tools = nil, proc = nil)
  model_name = model || @config["model"]
  body = build_chat_body(messages, 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.message}"
  raise LLMAPIError, "Failed to call SenseNova chat: #{e.message}"
end