openclacky 0.9.22 → 0.9.24

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 636621284dfcd7f7329f793bc80f2364f16a2777dedacd2fdf310b2a7f3be58a
4
- data.tar.gz: 5f3a12e563e36445d3aa32ae050f5b759b204325323cac119b8692bea7d3657b
3
+ metadata.gz: 5338bcdeaf4b2aafed416365f56467a99a6107911b24df2bfe9d94d757d03528
4
+ data.tar.gz: 8e06f6ca6b0d5a0a9c70ab758fc13b2f84d755f7d61e30a9708fdadd4db82309
5
5
  SHA512:
6
- metadata.gz: 23e59f8ae883ded129b4fee900c0ffdf6cec3ada4bd019e9a0243a3942abc70f5bbc8b9cc4830a34d7e66e22e4c663988295eed972ad1df63247c6f0bdbf1a68
7
- data.tar.gz: 218bd984151af6f087c6ac44f3aa213e19ee30981a1a69484344cdbd00774611465fc3847ecfc0933243e2ae6277792116d96d0aed04b9148a9cfed14f09e14e
6
+ metadata.gz: 7862cd557ea5ba9e88184b314fd88fc820d1f4e0c5a9bbebbfcdee75f33301876aff7df7f13cf07eaa21c7fb47d0efa65e7f051c1641ced6c20d1142e9e52819
7
+ data.tar.gz: b33e3cc3331e70328d366a38b67e472c760eb6bbe4b5621532ee4ae60585303d9042c4953f29f31e4256f357765d8aafee2b3e69fa87f31c386f9310be790c46
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.24] - 2026-04-02
11
+
12
+ ### Added
13
+ - **New session list & search in Web UI**: sidebar now shows full session history with real-time search — find any past conversation instantly
14
+ - **Session type indicators**: sessions are labeled by type (chat / agent) so you can see at a glance what kind of interaction it was
15
+ - **Image lightbox**: click any image in the chat to expand it full-screen with a clean overlay viewer
16
+ - **Session history replay for streaming messages**: chunk-based (streaming) messages are now fully replayed when revisiting a past session
17
+ - **Xiaomi AI provider**: added Xiaomi as a supported AI provider
18
+ - **Chinese Bing web search**: web search now uses cn.bing.com for users in China, improving search relevance and reliability
19
+ - **Auto-install system dependencies script**: agent can now automatically install missing system packages (Node, Python, etc.) via a bundled `install_system_deps.sh` script
20
+ - **User message timestamps**: each user message now displays the time it was sent
21
+
22
+ ### Fixed
23
+ - **Bedrock file attachments & partial cost tracking**: fixed file handling and cost accumulation for AWS Bedrock sessions
24
+ - **Session name timestamp**: fixed incorrect timestamp display on session names
25
+ - **New session scroll**: new sessions now correctly scroll to the latest message
26
+ - **Feishu WebSocket client crash**: fixed a nil-reference error that caused the Feishu WS client to crash on reconnect
27
+
28
+ ## [0.9.23] - 2026-04-01
29
+
30
+ ### Improved
31
+ - **API client model parameter propagation**: the Client class now accepts and uses an explicit model parameter, enabling better model detection and API routing across all client instantiation points (CLI, agent, subagent)
32
+ - **Bedrock API detection**: improved detection of Bedrock Converse API usage by checking both API key prefix (ABSK) and model prefix (abs-), providing more robust handling of Bedrock models
33
+
34
+ ### Fixed
35
+ - **CLI -c option model initialization**: fixed a bug where the CLI command with -c option was not passing the model name to the client, causing routing failures for certain providers
36
+
37
+ ### More
38
+ - ClackyAI provider updated to use the latest model name format (abs- prefix)
39
+
10
40
  ## [0.9.22] - 2026-03-31
11
41
 
12
42
  ### Added
@@ -305,9 +305,11 @@ module Clacky
305
305
  return nil unless @session_id && @created_at
306
306
 
307
307
  # Messages being compressed = original minus system message minus recent messages
308
+ # Also exclude system-injected scaffolding (session context, memory prompts, etc.)
309
+ # — these are internal CLI metadata and must not appear in chunk MD or WebUI history.
308
310
  recent_set = recent_messages.to_a
309
311
  messages_to_archive = original_messages.reject do |m|
310
- m[:role] == "system" || recent_set.include?(m)
312
+ m[:role] == "system" || m[:system_injected] || recent_set.include?(m)
311
313
  end
312
314
 
313
315
  return nil if messages_to_archive.empty?
@@ -374,9 +376,23 @@ module Clacky
374
376
  end
375
377
  lines << ""
376
378
  # Include tool calls summary if present
379
+ # Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
377
380
  if msg[:tool_calls]&.any?
378
- tool_names = msg[:tool_calls].map { |tc| tc.dig(:function, :name) }.compact.join(", ")
379
- lines << "_Tool calls: #{tool_names}_"
381
+ tc_parts = msg[:tool_calls].map do |tc|
382
+ name = tc.dig(:function, :name) || tc[:name] || ""
383
+ next nil if name.empty?
384
+
385
+ args_raw = tc.dig(:function, :arguments) || tc[:arguments] || {}
386
+ args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue nil) : args_raw
387
+ if args.is_a?(Hash) && !args.empty?
388
+ # Truncate large string values to keep chunk MD readable
389
+ compact = args.transform_values { |v| v.is_a?(String) && v.length > 200 ? v[0..197] + "..." : v }
390
+ "#{name} | #{compact.to_json}"
391
+ else
392
+ name
393
+ end
394
+ end.compact
395
+ lines << "_Tool calls: #{tc_parts.join("; ")}_"
380
396
  lines << ""
381
397
  end
382
398
  lines << format_message_content(content) if content
@@ -114,6 +114,7 @@ module Clacky
114
114
  # Replay conversation history by calling ui.show_* methods for each message.
115
115
  # Supports cursor-based pagination using created_at timestamps on user messages.
116
116
  # Each "round" starts at a user message and includes all subsequent assistant/tool messages.
117
+ # Compressed chunks (chunk_path on assistant messages) are transparently expanded.
117
118
  #
118
119
  # @param ui [Object] UI interface that responds to show_user_message, show_assistant_message, etc.
119
120
  # @param limit [Integer] Maximum number of rounds (user turns) to replay
@@ -147,9 +148,19 @@ module Clacky
147
148
  rounds << current_round
148
149
  elsif current_round
149
150
  current_round[:events] << msg
151
+ elsif msg[:compressed_summary] && msg[:chunk_path]
152
+ # Compressed summary sitting before any user rounds — expand it from chunk md
153
+ chunk_rounds = parse_chunk_md_to_rounds(msg[:chunk_path])
154
+ rounds.concat(chunk_rounds)
150
155
  end
151
156
  end
152
157
 
158
+ # Expand any compressed_summary assistant messages sitting inside a round's events.
159
+ # These occur when compression happened mid-round (rare) — expand them in-place.
160
+ rounds.each do |round|
161
+ round[:events].select! { |ev| !ev[:compressed_summary] }
162
+ end
163
+
153
164
  # Apply before-cursor filter: only rounds whose user message created_at < before
154
165
  if before
155
166
  rounds = rounds.select { |r| r[:user_msg][:created_at] && r[:user_msg][:created_at] < before }
@@ -193,6 +204,159 @@ module Clacky
193
204
  { has_more: has_more }
194
205
  end
195
206
 
207
+ # Parse a chunk MD file into an array of rounds compatible with replay_history.
208
+ # Each round is { user_msg: Hash, events: Array<Hash> }.
209
+ # Timestamps are synthesised from the chunk's archived_at, spread backwards.
210
+ # Recursively expands nested chunk references (compressed summary inside a chunk).
211
+ #
212
+ # @param chunk_path [String] Path to the chunk md file
213
+ # @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
214
+ private def parse_chunk_md_to_rounds(chunk_path)
215
+ return [] unless chunk_path && File.exist?(chunk_path.to_s)
216
+
217
+ raw = File.read(chunk_path.to_s, encoding: "utf-8")
218
+
219
+ # Parse YAML front matter to get archived_at for synthetic timestamps
220
+ archived_at = nil
221
+ if raw.start_with?("---")
222
+ fm_end = raw.index("\n---\n", 4)
223
+ if fm_end
224
+ fm_text = raw[4...fm_end]
225
+ fm_text.each_line do |line|
226
+ if line.start_with?("archived_at:")
227
+ archived_at = Time.parse(line.split(":", 2).last.strip) rescue nil
228
+ end
229
+ end
230
+ end
231
+ end
232
+ base_time = (archived_at || Time.now).to_f
233
+ chunk_dir = File.dirname(chunk_path.to_s)
234
+
235
+ # Split into sections by ## headings
236
+ sections = []
237
+ current_role = nil
238
+ current_lines = []
239
+ current_nested_chunk = nil # chunk reference from a Compressed Summary heading
240
+
241
+ raw.each_line do |line|
242
+ stripped = line.chomp
243
+ if (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
244
+ # Nested chunk reference — record it, treat as assistant section
245
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
246
+ current_role = "assistant"
247
+ current_lines = []
248
+ current_nested_chunk = File.join(chunk_dir, m[1])
249
+ elsif stripped.match?(/\A## (User|Assistant)/)
250
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
251
+ current_role = stripped.match(/\A## (User|Assistant)/)[1].downcase
252
+ current_lines = []
253
+ current_nested_chunk = nil
254
+ elsif stripped.match?(/\A### Tool Result:/)
255
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
256
+ current_role = "tool"
257
+ current_lines = []
258
+ current_nested_chunk = nil
259
+ else
260
+ current_lines << line
261
+ end
262
+ end
263
+ sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
264
+
265
+ # Remove front-matter / header noise sections (nil role or non-user/assistant/tool)
266
+ sections.select! { |s| %w[user assistant tool].include?(s[:role]) }
267
+
268
+ # Group into rounds: each user section starts a new round
269
+ rounds = []
270
+ current_round = nil
271
+ round_index = 0
272
+
273
+ sections.each do |sec|
274
+ text = sec[:lines].join.strip
275
+
276
+ # Nested chunk: expand it recursively, prepend before current rounds
277
+ if sec[:nested_chunk]
278
+ nested = parse_chunk_md_to_rounds(sec[:nested_chunk])
279
+ rounds = nested + rounds unless nested.empty?
280
+ # Also render its summary text as an assistant event in current round if any
281
+ if current_round && !text.empty?
282
+ current_round[:events] << { role: "assistant", content: text }
283
+ end
284
+ next
285
+ end
286
+
287
+ next if text.empty?
288
+
289
+ if sec[:role] == "user"
290
+ round_index += 1
291
+ # Synthetic timestamp: spread rounds backwards from archived_at
292
+ synthetic_ts = base_time - (sections.size - round_index) * 1.0
293
+ current_round = {
294
+ user_msg: {
295
+ role: "user",
296
+ content: text,
297
+ created_at: synthetic_ts,
298
+ _from_chunk: true
299
+ },
300
+ events: []
301
+ }
302
+ rounds << current_round
303
+ elsif current_round
304
+ if sec[:role] == "assistant"
305
+ # Detect "_Tool calls: ..._" lines — convert to tool_calls events
306
+ # so _replay_single_message renders them as tool group UI (same as live).
307
+ #
308
+ # Formats supported:
309
+ # New: "_Tool calls: name | {"arg":"val"}; name2 | {"k":"v"}_"
310
+ # Old: "_Tool calls: name1, name2_" (backward compat)
311
+ remaining_lines = []
312
+ pending_tool_entries = [] # [{name:, args:}]
313
+
314
+ text.each_line do |line|
315
+ stripped = line.strip
316
+ if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
317
+ raw = m[1]
318
+ # New format uses ";" as separator between tools (each entry: "name | {json}")
319
+ # Old format uses "," with no JSON part.
320
+ entries = raw.include?(" | ") ? raw.split(/;\s*/) : raw.split(/,\s*/)
321
+ entries.each do |entry|
322
+ entry = entry.strip
323
+ if (parts = entry.match(/\A(.+?)\s*\|\s*(\{.+\})\z/))
324
+ tool_name = parts[1].strip
325
+ args = JSON.parse(parts[2]) rescue {}
326
+ pending_tool_entries << { name: tool_name, args: args }
327
+ else
328
+ pending_tool_entries << { name: entry, args: {} }
329
+ end
330
+ end
331
+ else
332
+ remaining_lines << line
333
+ end
334
+ end
335
+
336
+ # Flush any plain text
337
+ plain_text = remaining_lines.join.strip
338
+ current_round[:events] << { role: "assistant", content: plain_text } unless plain_text.empty?
339
+
340
+ # Emit one synthetic tool_calls message per detected tool
341
+ pending_tool_entries.each do |entry|
342
+ current_round[:events] << {
343
+ role: "assistant",
344
+ content: "",
345
+ tool_calls: [{ name: entry[:name], arguments: entry[:args] }]
346
+ }
347
+ end
348
+ else
349
+ current_round[:events] << { role: "tool", content: text }
350
+ end
351
+ end
352
+ end
353
+
354
+ rounds
355
+ rescue => e
356
+ Clacky::Logger.warn("parse_chunk_md_to_rounds failed for #{chunk_path}: #{e.message}")
357
+ []
358
+ end
359
+
196
360
 
197
361
  # Render a single non-user message into the UI.
198
362
  # Used by both the normal round-based replay and the compressed-session fallback.
@@ -328,12 +492,14 @@ module Clacky
328
492
  next unless block[:type] == "image_url"
329
493
 
330
494
  url = block.dig(:image_url, :url)
331
- next unless url && url.start_with?("data:")
495
+ # image_path is stored at send-time so replay can reconstruct the image from tmp
496
+ path = block[:image_path]
497
+
498
+ next unless url&.start_with?("data:") || path
332
499
 
333
- # Derive mime_type from the data URL prefix (e.g. "data:image/jpeg;base64,...")
334
- mime_type = url[/\Adata:([^;]+);/, 1] || "image/jpeg"
500
+ mime_type = (url || "")[/\Adata:([^;]+);/, 1] || "image/jpeg"
335
501
  ext = mime_type.split("/").last
336
- { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url }
502
+ { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path }
337
503
  end
338
504
  end
339
505
  end
data/lib/clacky/agent.rb CHANGED
@@ -131,6 +131,7 @@ module Clacky
131
131
  @client = Clacky::Client.new(
132
132
  @config.api_key,
133
133
  base_url: @config.base_url,
134
+ model: @config.model_name,
134
135
  anthropic_format: @config.anthropic_format?
135
136
  )
136
137
  # Update message compressor with new client and model
@@ -217,7 +218,9 @@ module Clacky
217
218
  all_disk_files = disk_files + downgraded
218
219
 
219
220
  # Format user message — text + inline vision images
220
- user_content = format_user_content(user_input, vision_images.map { |v| v[:url] })
221
+ # Store the tmp path alongside the data_url so the history replay can
222
+ # reconstruct the image if the base64 was stripped (e.g. after compression).
223
+ user_content = format_user_content(user_input, vision_images.map { |v| { url: v[:url], path: v[:path] } })
221
224
 
222
225
  # Parse disk files — agent's responsibility, not the upload layer.
223
226
  # process_path runs the parser script and returns a FileRef with preview_path or parse_error.
@@ -923,6 +926,7 @@ module Clacky
923
926
  subagent_client = Clacky::Client.new(
924
927
  subagent_config.api_key,
925
928
  base_url: subagent_config.base_url,
929
+ model: subagent_config.model_name,
926
930
  anthropic_format: subagent_config.anthropic_format?
927
931
  )
928
932
 
@@ -1109,15 +1113,25 @@ module Clacky
1109
1113
 
1110
1114
  # Build user message content for LLM.
1111
1115
  # Returns plain String when no vision images; Array of content parts otherwise.
1112
- private def format_user_content(text, vision_urls)
1113
- vision_urls ||= []
1116
+ # Build user message content for LLM.
1117
+ # vision_images: Array of String (plain url) OR Hash { url:, path: }
1118
+ # path is stored in the block so history replay can reconstruct the image
1119
+ # from the tmp file when the base64 data_url is no longer available.
1120
+ private def format_user_content(text, vision_images)
1121
+ vision_images ||= []
1114
1122
 
1115
- return text if vision_urls.empty?
1123
+ return text if vision_images.empty?
1116
1124
 
1117
1125
  content = []
1118
1126
  content << { type: "text", text: text } unless text.nil? || text.empty?
1119
- vision_urls.each do |url|
1120
- content << { type: "image_url", image_url: { url: url } }
1127
+ vision_images.each do |img|
1128
+ if img.is_a?(Hash)
1129
+ block = { type: "image_url", image_url: { url: img[:url] } }
1130
+ block[:image_path] = img[:path] if img[:path]
1131
+ content << block
1132
+ else
1133
+ content << { type: "image_url", image_url: { url: img } }
1134
+ end
1121
1135
  end
1122
1136
  content
1123
1137
  end
@@ -1154,7 +1168,8 @@ module Clacky
1154
1168
  "Today is #{Time.now.strftime('%Y-%m-%d, %A')}",
1155
1169
  "Current model: #{current_model}",
1156
1170
  os != :unknown ? "OS: #{Clacky::Utils::EnvironmentDetector.os_label}" : nil,
1157
- desktop ? "Desktop: #{desktop}" : nil
1171
+ desktop ? "Desktop: #{desktop}" : nil,
1172
+ "Working directory: #{@working_dir}"
1158
1173
  ].compact.join(". ")
1159
1174
 
1160
1175
  content = "[Session context: #{parts}]"
@@ -325,9 +325,9 @@ module Clacky
325
325
  current_model&.dig("anthropic_format") || false
326
326
  end
327
327
 
328
- # Check if current model uses AWS Bedrock API key (ABSK prefix)
328
+ # Check if current model uses Bedrock Converse API (ABSK key prefix or abs- model prefix)
329
329
  def bedrock?
330
- Clacky::MessageFormat::Bedrock.bedrock_api_key?(api_key.to_s)
330
+ Clacky::MessageFormat::Bedrock.bedrock_api_key?(api_key.to_s, model_name.to_s)
331
331
  end
332
332
 
333
333
  # Add a new model configuration
@@ -434,6 +434,8 @@ module Clacky
434
434
  if data.is_a?(Array)
435
435
  # New format: top-level array of model configurations
436
436
  models = data.map do |m|
437
+ # Deep copy to avoid shared references between models
438
+ m = m.dup.transform_values { |v| v.is_a?(String) ? v.dup : v }
437
439
  # Convert old name-based format to new model-based format if needed
438
440
  if m["name"] && !m["model"]
439
441
  m["model"] = m["name"]
data/lib/clacky/cli.rb CHANGED
@@ -84,7 +84,7 @@ module Clacky
84
84
  agent_config.verbose = options[:verbose] if options[:verbose]
85
85
 
86
86
  # Create client for current model
87
- client = Clacky::Client.new(agent_config.api_key, base_url: agent_config.base_url, anthropic_format: agent_config.anthropic_format?)
87
+ client = Clacky::Client.new(agent_config.api_key, base_url: agent_config.base_url, model: agent_config.model_name, anthropic_format: agent_config.anthropic_format?)
88
88
 
89
89
  # Resolve agent profile name from --agent option
90
90
  agent_profile = options[:agent] || "coding"
@@ -137,6 +137,7 @@ module Clacky
137
137
  test_client = Clacky::Client.new(
138
138
  test_config.api_key,
139
139
  base_url: test_config.base_url,
140
+ model: test_config.model_name,
140
141
  anthropic_format: test_config.anthropic_format?
141
142
  )
142
143
 
@@ -162,6 +163,7 @@ module Clacky
162
163
  agent.instance_variable_set(:@client, Clacky::Client.new(
163
164
  config.api_key,
164
165
  base_url: config.base_url,
166
+ model: config.model_name,
165
167
  anthropic_format: config.anthropic_format?
166
168
  ))
167
169
 
@@ -829,6 +831,7 @@ module Clacky
829
831
  Clacky::Client.new(
830
832
  agent_config.api_key,
831
833
  base_url: agent_config.base_url,
834
+ model: agent_config.model_name,
832
835
  anthropic_format: agent_config.anthropic_format?
833
836
  )
834
837
  end
data/lib/clacky/client.rb CHANGED
@@ -8,13 +8,13 @@ module Clacky
8
8
  MAX_RETRIES = 10
9
9
  RETRY_DELAY = 5 # seconds
10
10
 
11
- def initialize(api_key, base_url:, model: nil, anthropic_format: false)
11
+ def initialize(api_key, base_url:, model:, anthropic_format: false)
12
12
  @api_key = api_key
13
13
  @base_url = base_url
14
14
  @model = model
15
15
  @use_anthropic_format = anthropic_format
16
- # Detect Bedrock API key by ABSK prefix overrides anthropic_format routing
17
- @use_bedrock = MessageFormat::Bedrock.bedrock_api_key?(api_key)
16
+ # Detect Bedrock: ABSK key prefix (native AWS) or abs- model prefix (Clacky AI proxy)
17
+ @use_bedrock = MessageFormat::Bedrock.bedrock_api_key?(api_key, model)
18
18
  end
19
19
 
20
20
  # Returns true when the client is using the AWS Bedrock Converse API.
@@ -168,6 +168,8 @@ module Clacky
168
168
  # user message as a cache breakpoint, which invalidates the intended cache boundary
169
169
  # and results in cache misses (cache_read=0) every turn.
170
170
  blocks = content_to_blocks(content)
171
+ # Anthropic rejects messages with an empty content array — use a placeholder text block.
172
+ blocks = [{ type: "text", text: "..." }] if blocks.empty?
171
173
  { role: role, content: blocks }
172
174
  end
173
175
 
@@ -176,11 +178,17 @@ module Clacky
176
178
  private_class_method def self.content_to_blocks(content)
177
179
  case content
178
180
  when String
181
+ # Anthropic rejects blank text blocks — skip empty strings
182
+ return [] if content.empty?
183
+
179
184
  [{ type: "text", text: content }]
180
185
  when Array
181
186
  content.map { |b| normalize_block(b) }.compact
182
187
  else
183
- [{ type: "text", text: content.to_s }]
188
+ str = content.to_s
189
+ return [] if str.empty?
190
+
191
+ [{ type: "text", text: str }]
184
192
  end
185
193
  end
186
194
 
@@ -190,8 +198,12 @@ module Clacky
190
198
 
191
199
  case block[:type]
192
200
  when "text"
201
+ # Anthropic rejects blank text blocks — drop them instead of sending { type:"text", text:"" }
202
+ text = block[:text]
203
+ return nil if text.nil? || text.empty?
204
+
193
205
  # Preserve cache_control if present (placed by Client#apply_message_caching)
194
- result = { type: "text", text: block[:text] }
206
+ result = { type: "text", text: text }
195
207
  result[:cache_control] = block[:cache_control] if block[:cache_control]
196
208
  result
197
209
  when "image_url"
@@ -16,9 +16,12 @@ module Clacky
16
16
  #
17
17
  # This module converts canonical format ↔ Bedrock Converse API format.
18
18
  module Bedrock
19
- # Detect if the API key is an AWS Bedrock API key (ABSK prefix)
20
- def self.bedrock_api_key?(api_key)
21
- api_key.to_s.start_with?("ABSK")
19
+ # Detect if the request should use the Bedrock Converse API.
20
+ # Matches either:
21
+ # - API key with "ABSK" prefix (native AWS Bedrock)
22
+ # - Model ID with "abs-" prefix (Clacky AI proxy that speaks Bedrock Converse)
23
+ def self.bedrock_api_key?(api_key, model)
24
+ api_key.to_s.start_with?("ABSK") || model.to_s.start_with?("abs-")
22
25
  end
23
26
 
24
27
  module_function
@@ -177,6 +180,8 @@ module Clacky
177
180
 
178
181
  # regular user/assistant message
179
182
  blocks = content_to_blocks(content)
183
+ # Bedrock rejects messages with an empty content array — use a placeholder text block.
184
+ blocks = [{ text: "..." }] if blocks.empty?
180
185
  { role: role, content: blocks }
181
186
  end
182
187
 
@@ -184,11 +189,17 @@ module Clacky
184
189
  private_class_method def self.content_to_blocks(content)
185
190
  case content
186
191
  when String
192
+ # Bedrock rejects blank text blocks — skip empty strings
193
+ return [] if content.empty?
194
+
187
195
  [{ text: content }]
188
196
  when Array
189
197
  content.map { |b| normalize_block(b) }.compact
190
198
  else
191
- [{ text: content.to_s }]
199
+ str = content.to_s
200
+ return [] if str.empty?
201
+
202
+ [{ text: str }]
192
203
  end
193
204
  end
194
205
 
@@ -198,7 +209,11 @@ module Clacky
198
209
 
199
210
  case block[:type]
200
211
  when "text"
201
- { text: block[:text].to_s }
212
+ # Bedrock rejects blank text blocks — drop them
213
+ text = block[:text].to_s
214
+ return nil if text.empty?
215
+
216
+ { text: text }
202
217
  when "image_url"
203
218
  # Bedrock image format — base64 only
204
219
  url = block.dig(:image_url, :url) || block[:url]
@@ -49,15 +49,25 @@ module Clacky
49
49
  }.freeze,
50
50
 
51
51
  "clackyai" => {
52
- "name" => "Clacky AI",
52
+ "name" => "ClackyAI",
53
53
  "base_url" => "https://api.clacky.ai",
54
54
  "api" => "bedrock",
55
- "default_model" => "jp.anthropic.claude-sonnet-4-6",
55
+ "default_model" => "abs-claude-sonnet-4-6",
56
56
  "models" => [
57
- "jp.anthropic.claude-sonnet-4-6",
58
- "jp.anthropic.claude-haiku-4-6"
57
+ "abs-claude-opus-4-6",
58
+ "abs-claude-sonnet-4-6",
59
+ "abs-claude-haiku-4-5"
59
60
  ],
60
61
  "website_url" => "https://clacky.ai"
62
+ }.freeze,
63
+
64
+ "mimo" => {
65
+ "name" => "MiMo (Xiaomi)",
66
+ "base_url" => "https://api.xiaomimimo.com/v1",
67
+ "api" => "openai-completions",
68
+ "default_model" => "mimo-v2-pro",
69
+ "models" => ["mimo-v2-pro", "mimo-v2-omni"],
70
+ "website_url" => "https://platform.xiaomimimo.com/"
61
71
  }.freeze
62
72
 
63
73
  }.freeze
@@ -160,6 +160,12 @@ module Clacky
160
160
  @ping_interval = (client_config["PingInterval"] || 90).to_i
161
161
 
162
162
  url = data.dig("data", "URL")
163
+ if url.nil? || url.strip.empty?
164
+ Clacky::Logger.error("[feishu-ws] WebSocket endpoint URL is missing from response. " \
165
+ "Please verify your Feishu App ID and App Secret are correct.")
166
+ raise "Failed to get WebSocket endpoint: URL is missing (check your Feishu App ID / App Secret)"
167
+ end
168
+
163
169
  if url =~ /service_id=(\d+)/
164
170
  @service_id = $1.to_i
165
171
  end