openclacky 1.4.0 → 1.5.0

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/lib/clacky/agent/llm_caller.rb +3 -3
  4. data/lib/clacky/agent/session_serializer.rb +50 -9
  5. data/lib/clacky/agent.rb +4 -5
  6. data/lib/clacky/brand_config.rb +102 -10
  7. data/lib/clacky/client.rb +22 -16
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +135 -84
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +144 -1
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -4
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +412 -173
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +522 -0
  13. data/lib/clacky/extension/api_extension.rb +5 -6
  14. data/lib/clacky/extension/loader.rb +16 -2
  15. data/lib/clacky/extension/packager.rb +10 -1
  16. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  17. data/lib/clacky/mcp/http_transport.rb +1 -1
  18. data/lib/clacky/platform_http_client.rb +9 -7
  19. data/lib/clacky/providers.rb +113 -5
  20. data/lib/clacky/server/http_server.rb +131 -38
  21. data/lib/clacky/server/session_registry.rb +9 -0
  22. data/lib/clacky/tools/grep.rb +6 -1
  23. data/lib/clacky/utils/environment_detector.rb +16 -9
  24. data/lib/clacky/utils/file_processor.rb +21 -24
  25. data/lib/clacky/utils/model_pricing.rb +85 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +441 -48
  28. data/lib/clacky/web/app.js +29 -6
  29. data/lib/clacky/web/components/onboard.js +21 -7
  30. data/lib/clacky/web/features/backup/view.js +4 -3
  31. data/lib/clacky/web/features/extensions/store.js +56 -4
  32. data/lib/clacky/web/features/extensions/view.js +54 -24
  33. data/lib/clacky/web/features/mcp/view.js +5 -2
  34. data/lib/clacky/web/features/new-session/store.js +8 -4
  35. data/lib/clacky/web/features/version/view.js +5 -1
  36. data/lib/clacky/web/features/workspace/store.js +11 -0
  37. data/lib/clacky/web/features/workspace/view.js +10 -4
  38. data/lib/clacky/web/i18n.js +58 -36
  39. data/lib/clacky/web/index.html +74 -7
  40. data/lib/clacky/web/sessions.js +72 -15
  41. data/lib/clacky/web/settings.js +22 -3
  42. data/lib/clacky/web/theme.js +27 -58
  43. data/scripts/uninstall.sh +1 -1
  44. metadata +2 -5
  45. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +0 -71
  46. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +0 -73
  47. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +0 -65
  48. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +0 -106
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29e2af05d27f902fc645a1399eb16d16d41681ec03312ee861fdd594292368c2
4
- data.tar.gz: bbb62ec065f07867a2085b6dc0f81d5702c34cf0ff4e10e0b88513daa5fb4541
3
+ metadata.gz: 85707cf41b28f7283581fde4a77cbdf136ac525d9671743c13cfe268cf71c230
4
+ data.tar.gz: f2ccf13cf87f8aeb4e9ac2a27a7f7d3f1c8be53f196d52b39d412ae54bbd791a
5
5
  SHA512:
6
- metadata.gz: bf7f4ad60821686404ee3b43607e59c51cb0d38420171b94eaea2c6d2877d71c03052f9147b56f61aeb5fc01155127d4aab15a8f0b6ecc0b73d6b294fb0bbe3c
7
- data.tar.gz: ba3a05a10e80fd8b48f10f0778388c5417ce8fa79e0f3936cb6ef734117609bc85f56868227e174675e4132d0c619a8b385619c7aaf62e75039fc3f4895d15f8
6
+ metadata.gz: 33a52baaf3983e25e121d6e5200424d57b20d50af5226168df47b730e202add9ab296ecf9d480dc1380302d95b24273ad399b808290954875a139748c4879de8
7
+ data.tar.gz: c06c55d0765c8f657606dae02f214aeb938dc484522af5698bcd408b34379160e2bd850a9abdc91a82d4bf3f9925a20b698c3973a35839bb8886170f10d4215d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.0] - 2026-07-23
8
+
9
+ ### Added
10
+ - Volcengine Ark (Doubao) provider with endpoint-aware model-id mapping
11
+ - Brand tab in extension marketplace for brand-private extensions
12
+ - Auto-install all brand skills on license activation
13
+ - Multi-background theme support with refactored theme engine
14
+ - Short hash suffix in session-info-bar session file label with tooltips
15
+ - Guide ext-develop skill to the host-API reference
16
+
17
+ ### Improved
18
+ - Ext-developer skill prompt updated with engineering discipline guidelines
19
+ - ext-develop skill merged into a single consolidated guide
20
+ - Brand status fetch moved from view layer to store (cleaner architecture)
21
+ - README check prompted before publishing extension
22
+ - MCP page copy updated (removed Clacky brand name)
23
+ - Floating window border effect added to content area
24
+
25
+ ### Fixed
26
+ - CRLF normalization in SSE chunks for servers sending `\r\n\r\n` event separators
27
+ - Subagent tool-item completion with DOM fallback when element not yet in DOM
28
+ - AgentError now carries raw_message for LLM network/timeout errors
29
+ - Extension data persisted outside package dir (C-5729)
30
+ - Idle compression worker shutdown before process exit
31
+ - Install button shown for all extension origins when download_url is present
32
+
33
+ ## [1.4.1] - 2026-07-20
34
+
35
+ ### Added
36
+ - Extension README edit/validate flow and screenshot upload in ext-studio publish
37
+ - Kimi k3 and k2.7-code models added to Kimi preset
38
+ - New session split-button with improved styling
39
+ - Rename-on-name-conflict flow in ext-studio publish
40
+ - Skip OpenClacky AI Keys card for branded onboarding
41
+
42
+ ### Improved
43
+ - Secondary API host added with reduced failover latency
44
+ - Extensions default sort changed to most-installed (downloads first)
45
+
46
+ ### Fixed
47
+ - WSL file panel "Copy Path" now copies Windows-accessible UNC path via `wslpath` (C-5693)
48
+ - Edit button hidden on compressed user messages archived to chunk (C-5723)
49
+ - Message-edit Enter key blocked during IME composition (C-5742)
50
+ - Markdown images with `file://~/` tilde paths now render correctly (C-5747)
51
+ - Local `file://` path resolution unified for Windows drive letters (C-5746, C-5708)
52
+ - Brand extension installs under its real ID, not as unpacked (C-5735)
53
+ - Backup success status no longer stays displayed forever (C-5711)
54
+ - `grep` tool defaults to recursive behavior
55
+ - `display_name` fields included in installed extensions API response
56
+ - Published list reloads after setup entry point and publish
57
+ - Unpublish button size matches iterate button in ext-studio
58
+ - `device_token` correctly passed in screenshot upload multipart fields
59
+ - MCP tool list rendering fixed and display styling improved
60
+ - Uninstall script now removes all openclacky gem versions
61
+
7
62
  ## [1.4.0] - 2026-07-14
8
63
 
9
64
  ### Added
@@ -201,7 +201,7 @@ module Clacky
201
201
  sleep retry_delay
202
202
  retry
203
203
  else
204
- raise AgentError, "[LLM] #{I18n.t("llm.error.request_timeout", retries: max_retries)}"
204
+ raise AgentError.new("[LLM] #{I18n.t("llm.error.request_timeout", retries: max_retries)}", raw_message: e.message)
205
205
  end
206
206
 
207
207
  rescue Faraday::ConnectionFailed, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EPIPE => e
@@ -236,7 +236,7 @@ module Clacky
236
236
  else
237
237
  # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
238
238
  # Progress cleanup is the caller's responsibility (via its own ensure block).
239
- raise AgentError, "[LLM] #{I18n.t("llm.error.network_failed", retries: max_retries)}"
239
+ raise AgentError.new("[LLM] #{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
240
240
  end
241
241
 
242
242
  rescue RetryableError => e
@@ -274,7 +274,7 @@ module Clacky
274
274
  else
275
275
  # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
276
276
  # Progress cleanup is the caller's responsibility (via its own ensure block).
277
- raise AgentError, "[LLM] #{I18n.t("llm.error.service_unavailable", retries: current_max)}"
277
+ raise AgentError.new("[LLM] #{I18n.t("llm.error.service_unavailable", retries: current_max)}", raw_message: e.message)
278
278
  end
279
279
 
280
280
  rescue Clacky::BadRequestError => e
@@ -68,6 +68,7 @@ module Clacky
68
68
  }
69
69
  end
70
70
  backfill_task_meta_from_history!
71
+ heal_missing_chunk_paths!
71
72
 
72
73
  # Check if the session ended with an error.
73
74
  # We record the rollback intent here but do NOT truncate history immediately —
@@ -143,6 +144,31 @@ module Clacky
143
144
  end
144
145
  end
145
146
 
147
+ # Repair compressed_summary messages whose chunk_path is missing or points
148
+ # at a file that no longer exists. This happens when a hot restart's
149
+ # SIGKILL killed the worker after the chunk file was written to disk but
150
+ # before its path was persisted into session.json — the archived history
151
+ # then never renders on replay. Rediscover the chunk on disk from
152
+ # session_id + created_at and write the real path back, once, at load time.
153
+ private def heal_missing_chunk_paths!
154
+ return unless @session_id && @created_at
155
+
156
+ broken = @history.to_a.select do |m|
157
+ m[:compressed_summary] && !(m[:chunk_path] && File.exist?(m[:chunk_path].to_s))
158
+ end
159
+ return if broken.empty?
160
+
161
+ chunks = session_manager.chunks_for_current(@session_id, @created_at)
162
+ return if chunks.empty?
163
+
164
+ latest_path = chunks.last[:path]
165
+ broken.each { |m| m[:chunk_path] = latest_path }
166
+
167
+ session_manager.save(to_session_data(preserve_updated_at: true))
168
+ rescue => e
169
+ Clacky::Logger.warn("heal_missing_chunk_paths! failed: #{e.message}")
170
+ end
171
+
146
172
  private def persisted_card_field(key)
147
173
  card_id = @config.current_model_id
148
174
  return nil unless card_id
@@ -257,12 +283,14 @@ module Clacky
257
283
  end
258
284
 
259
285
  if is_real_user_msg
260
- # Start a new round at each real user message
261
- current_round = { user_msg: msg, events: [] }
286
+ # Start a new round at each real user message.
287
+ # editable: true this message still lives in the active in-memory
288
+ # @history, so truncate_from_created_at can locate and truncate it.
289
+ current_round = { user_msg: msg, events: [], editable: true }
262
290
  rounds << current_round
263
291
  elsif current_round
264
292
  current_round[:events] << msg
265
- elsif msg[:compressed_summary] && msg[:chunk_path]
293
+ elsif msg[:compressed_summary]
266
294
  # Compressed summary sitting before any user rounds — expand ALL chunk
267
295
  # MD files that belong to the same session (siblings of chunk_path),
268
296
  # in chunk-index ascending order.
@@ -272,15 +300,23 @@ module Clacky
272
300
  # points at the newest chunk). Older chunks (chunk-1..chunk-N-1) are
273
301
  # referenced only as basenames inside the summary text. Expanding just
274
302
  # msg[:chunk_path] would therefore lose all prior chunks on replay.
275
- chunk_rounds = sibling_chunks_of(msg[:chunk_path]).flat_map { |p|
276
- parse_chunk_md_to_rounds(p)
277
- }
303
+ #
304
+ # chunk_path may be blank when a hot restart's SIGKILL killed the
305
+ # worker after the chunk file was written but before its path was
306
+ # persisted into session.json. Fall back to discovering the chunks
307
+ # on disk from session_id + created_at so the history is not lost.
308
+ chunk_paths = if msg[:chunk_path].to_s.empty?
309
+ session_manager.chunks_for_current(@session_id, @created_at).map { |c| c[:path] }
310
+ else
311
+ sibling_chunks_of(msg[:chunk_path])
312
+ end
313
+ chunk_rounds = chunk_paths.flat_map { |p| parse_chunk_md_to_rounds(p) }
278
314
  rounds.concat(chunk_rounds)
279
315
  # After expanding, treat the last chunk round as the current round so that
280
316
  # any orphaned assistant/tool messages that follow in session.json (belonging
281
317
  # to the same task whose user message was compressed into the chunk) get
282
318
  # appended here instead of being silently discarded.
283
- current_round = rounds.last
319
+ current_round = rounds.last unless chunk_rounds.empty?
284
320
  elsif rounds.last
285
321
  # Orphaned non-user message with no current_round yet (e.g. recent_messages
286
322
  # after compression started mid-task with no leading user message).
@@ -325,7 +361,8 @@ module Clacky
325
361
  preview_path: f[:preview_path] || f["preview_path"] }
326
362
  }
327
363
  all_files = image_files + disk_files
328
- ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files)
364
+ ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files,
365
+ editable: round[:editable] != false)
329
366
 
330
367
  round[:events].each do |ev|
331
368
  # Skip system-injected messages (e.g. synthetic skill content, memory prompts)
@@ -480,7 +517,11 @@ module Clacky
480
517
  created_at: synthetic_ts,
481
518
  _from_chunk: true
482
519
  },
483
- events: []
520
+ events: [],
521
+ # editable: false — this message was archived into a chunk MD and no
522
+ # longer exists in the active in-memory @history, so it cannot be
523
+ # truncated/edited (truncate_from_created_at would silently no-op).
524
+ editable: false
484
525
  }
485
526
  rounds << current_round
486
527
  elsif current_round
data/lib/clacky/agent.rb CHANGED
@@ -1862,11 +1862,10 @@ module Clacky
1862
1862
  files = []
1863
1863
  content.scan(/(!?)\[([^\]]*)\]\(file:\/\/([^)]+)\)/) do
1864
1864
  inline = $1 == "!"
1865
- # URL-decode percent-encoded characters (e.g. Chinese filenames encoded by AI)
1866
- raw_path = CGI.unescape($3)
1867
- name = File.basename(raw_path)
1868
- path = File.expand_path(raw_path)
1869
- Clacky::Logger.info("[parse_file_links] raw=#{$3.inspect} expanded=#{path.inspect} exist=#{File.exist?(path)}")
1865
+ # Resolve the AI-emitted path: decode, WSL drive-letter normalize, expand.
1866
+ path = Clacky::Utils::EnvironmentDetector.resolve_local_path($3)
1867
+ name = File.basename(path)
1868
+ Clacky::Logger.info("[parse_file_links] raw=#{$3.inspect} resolved=#{path.inspect} exist=#{File.exist?(path)}")
1870
1869
  files << { name: name, path: path, inline: inline }
1871
1870
  end
1872
1871
  { text: content, files: files }
@@ -622,7 +622,7 @@ module Clacky
622
622
  #
623
623
  # Returns { success: true, extension: {...} } or
624
624
  # { success: false, error: "...", already_exists: Boolean }.
625
- def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, origin: 'marketplace')
625
+ def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, readme: nil, origin: 'marketplace')
626
626
  identity = Clacky::Identity.load
627
627
  return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
628
628
 
@@ -636,6 +636,7 @@ module Clacky
636
636
  fields["origin"] = origin.to_s if origin
637
637
  fields["status"] = status.to_s if status
638
638
  fields["changelog"] = changelog.to_s if changelog
639
+ fields["readme"] = readme.to_s if readme
639
640
 
640
641
  body_bytes, boundary = build_multipart(fields, "extension_zip", "#{ext_id}.zip", zip_data)
641
642
 
@@ -700,6 +701,55 @@ module Clacky
700
701
  end
701
702
  end
702
703
 
704
+ # Update an extension's readme text without publishing a new version.
705
+ # Uses PATCH /api/v1/client/extensions/:id.
706
+ # Returns { success:, extension:, error: }
707
+ def update_extension_readme!(ext_id, readme)
708
+ identity = Clacky::Identity.load
709
+ return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
710
+
711
+ path = "/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}"
712
+ payload = { "device_token" => identity.device_token, "readme" => readme.to_s }
713
+ result = platform_client.patch(path, payload)
714
+
715
+ if result[:success]
716
+ { success: true, extension: result[:data]["extension"] }
717
+ else
718
+ body = result[:data] || {}
719
+ { success: false, error: result[:error] || body["code"] || "Update readme failed" }
720
+ end
721
+ rescue StandardError => e
722
+ { success: false, error: "Network error: #{e.message}" }
723
+ end
724
+
725
+ # Upload any file to the platform's model-agnostic upload endpoint.
726
+ # Returns an orphan blob URL — no extension record required.
727
+ # Uses POST /api/v1/client/uploads.
728
+ # Parameters:
729
+ # data (bytes) - raw file bytes
730
+ # filename (string) - filename including extension
731
+ # content_type (string) - MIME type
732
+ # Returns { success:, url:, filename:, blob_key:, error: }
733
+ def upload_file!(data, filename:, content_type: "image/png")
734
+ identity = Clacky::Identity.load
735
+ return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
736
+
737
+ path = "/api/v1/client/uploads"
738
+ fields = { "device_token" => identity.device_token }
739
+ body_bytes, boundary = build_multipart(fields, "file", filename, data, content_type: content_type)
740
+ result = platform_client.multipart_post(path, body_bytes, boundary, read_timeout: 60)
741
+
742
+ if result[:success]
743
+ d = result[:data] || {}
744
+ { success: true, url: d["url"], filename: d["filename"], blob_key: d["blob_key"] }
745
+ else
746
+ body = result[:data] || {}
747
+ { success: false, error: result[:error] || body["code"] || "File upload failed" }
748
+ end
749
+ rescue StandardError => e
750
+ { success: false, error: "Network error: #{e.message}" }
751
+ end
752
+
703
753
  # Search the public extension marketplace. Anonymous — no license required.
704
754
  # Uses GET /api/v1/extensions. Returns { success:, extensions: [], error: }.
705
755
  def search_extensions!(query: nil, sort: nil)
@@ -732,6 +782,41 @@ module Clacky
732
782
  rescue StandardError => e
733
783
  { success: false, error: "Network error: #{e.message}" }
734
784
  end
785
+
786
+ # Fetch detail for a single brand-private extension via the license-gated
787
+ # POST /api/v1/licenses/extension_detail endpoint.
788
+ # The public /api/v1/extensions/:id intentionally omits origin=self extensions,
789
+ # so activated brand users must use this signed request instead.
790
+ # Returns { success:, extension:, error: }.
791
+ def brand_extension_detail!(id)
792
+ return { success: false, error: "License not activated" } unless activated?
793
+
794
+ user_id = parse_user_id_from_key(@license_key)
795
+ ts = Time.now.utc.to_i.to_s
796
+ nonce = SecureRandom.hex(16)
797
+ message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
798
+
799
+ payload = {
800
+ key_hash: Digest::SHA256.hexdigest(@license_key),
801
+ user_id: user_id.to_s,
802
+ device_id: @device_id,
803
+ timestamp: ts,
804
+ nonce: nonce,
805
+ signature: OpenSSL::HMAC.hexdigest("SHA256", @license_key, message),
806
+ id: id.to_s
807
+ }
808
+
809
+ response = api_post("/api/v1/licenses/extension", payload)
810
+
811
+ if response[:success]
812
+ { success: true, extension: response[:data]["extension"] }
813
+ else
814
+ { success: false, error: response[:error] || "Extension not found" }
815
+ end
816
+ rescue StandardError => e
817
+ { success: false, error: "Network error: #{e.message}" }
818
+ end
819
+
735
820
  # Extensions bundled into the activated license's distribution are free and
736
821
  # unencrypted. They are fetched over the same license-HMAC scheme as brand
737
822
  # skills and installed into the ExtensionLoader `installed` layer.
@@ -1163,7 +1248,10 @@ module Clacky
1163
1248
  # @param on_complete [Proc, nil] Optional callback called with the sync
1164
1249
  # results array once all downloads finish (useful for tests / UI feedback).
1165
1250
  # @return [Thread, nil] The background thread, or nil if skipped.
1166
- def sync_brand_skills_async!(on_complete: nil)
1251
+ # install_new: when true, install ALL remote skills regardless of whether they
1252
+ # were previously installed. Used on first activation so every brand skill is
1253
+ # available immediately without the user clicking Install one-by-one.
1254
+ def sync_brand_skills_async!(on_complete: nil, install_new: false)
1167
1255
  return nil unless activated?
1168
1256
  return nil if ENV["CLACKY_TEST"] == "1"
1169
1257
 
@@ -1182,13 +1270,17 @@ module Clacky
1182
1270
  delete_brand_skill!(local_name) unless remote_skill_names.include?(local_name)
1183
1271
  end
1184
1272
 
1185
- # Auto-sync is intentionally limited to skills the user has already
1186
- # installed and that have a newer version available.
1187
- # New skills are never auto-installedthe user must click Install/Update
1188
- # explicitly from the Brand Skills panel.
1273
+ # When install_new is true (e.g. on first activation) install every
1274
+ # remote skill. Otherwise limit auto-sync to skills already installed
1275
+ # locally that have a newer version available new skills must be
1276
+ # installed explicitly by the user from the Brand Skills panel.
1189
1277
  installed = installed_brand_skills
1190
- skills_needing_update = result[:skills].select { |s| s["needs_update"] }
1191
- results = skills_needing_update.map do |skill_info|
1278
+ skills_to_install = if install_new
1279
+ result[:skills].select { |s| s["needs_update"] || !installed.key?(s["name"]) }
1280
+ else
1281
+ result[:skills].select { |s| s["needs_update"] }
1282
+ end
1283
+ results = skills_to_install.map do |skill_info|
1192
1284
  install_brand_skill!(skill_info)
1193
1285
  end
1194
1286
 
@@ -1579,7 +1671,7 @@ module Clacky
1579
1671
 
1580
1672
  # Assemble a binary multipart/form-data body: text fields + one file part.
1581
1673
  # Kept binary-safe so null bytes in the ZIP survive. Returns [body, boundary].
1582
- private def build_multipart(fields, file_field, filename, file_bytes)
1674
+ private def build_multipart(fields, file_field, filename, file_bytes, content_type: "application/zip")
1583
1675
  boundary = "----ClackyMultipart#{SecureRandom.hex(8)}"
1584
1676
  crlf = "\r\n"
1585
1677
  parts = []
@@ -1591,7 +1683,7 @@ module Clacky
1591
1683
  end
1592
1684
  parts << "--#{boundary}#{crlf}"
1593
1685
  parts << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"#{crlf}"
1594
- parts << "Content-Type: application/zip#{crlf}#{crlf}"
1686
+ parts << "Content-Type: #{content_type}#{crlf}#{crlf}"
1595
1687
  parts << file_bytes.b
1596
1688
  parts << "#{crlf}--#{boundary}--#{crlf}"
1597
1689
  [parts.map(&:b).join, boundary]
data/lib/clacky/client.rb CHANGED
@@ -55,17 +55,18 @@ module Clacky
55
55
  # Test API connection by sending a minimal request.
56
56
  # Returns { success: true } or { success: false, error: "..." }.
57
57
  def test_connection(model:)
58
+ api_model = Providers.resolve_api_model(base_url: @base_url, api_key: @api_key, model: model)
58
59
  if bedrock?
59
60
  body = MessageFormat::Bedrock.build_request_body(
60
- [{ role: :user, content: "hi" }], model, [], 16
61
+ [{ role: :user, content: "hi" }], api_model, [], 16
61
62
  ).to_json
62
- response = bedrock_connection.post(bedrock_endpoint(model)) { |r| r.body = body }
63
+ response = bedrock_connection.post(bedrock_endpoint(api_model)) { |r| r.body = body }
63
64
  elsif anthropic_format?
64
- minimal_body = { model: model, max_tokens: 16,
65
+ minimal_body = { model: api_model, max_tokens: 16,
65
66
  messages: [{ role: "user", content: "hi" }] }.to_json
66
67
  response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = minimal_body }
67
68
  else
68
- minimal_body = { model: model, max_tokens: 16,
69
+ minimal_body = { model: api_model, max_tokens: 16,
69
70
  messages: [{ role: "user", content: "hi" }] }.to_json
70
71
  response = openai_connection.post("chat/completions") { |r| r.body = minimal_body }
71
72
  end
@@ -87,16 +88,17 @@ module Clacky
87
88
 
88
89
  # Send a messages array and return the reply text.
89
90
  def send_messages(messages, model:, max_tokens:)
91
+ api_model = Providers.resolve_api_model(base_url: @base_url, api_key: @api_key, model: model)
90
92
  if bedrock?
91
- body = MessageFormat::Bedrock.build_request_body(messages, model, [], max_tokens)
92
- response = bedrock_connection.post(bedrock_endpoint(model)) { |r| r.body = body.to_json }
93
+ body = MessageFormat::Bedrock.build_request_body(messages, api_model, [], max_tokens)
94
+ response = bedrock_connection.post(bedrock_endpoint(api_model)) { |r| r.body = body.to_json }
93
95
  parse_simple_bedrock_response(response)
94
96
  elsif anthropic_format?
95
- body = MessageFormat::Anthropic.build_request_body(messages, model, [], max_tokens, false)
97
+ body = MessageFormat::Anthropic.build_request_body(messages, api_model, [], max_tokens, false)
96
98
  response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = body.to_json }
97
99
  parse_simple_anthropic_response(response)
98
100
  else
99
- body = { model: model, max_tokens: max_tokens, messages: messages }
101
+ body = { model: api_model, max_tokens: max_tokens, messages: messages }
100
102
  response = openai_connection.post("chat/completions") { |r| r.body = body.to_json }
101
103
  parse_simple_openai_response(response)
102
104
  end
@@ -127,6 +129,7 @@ module Clacky
127
129
  # a single synthetic invocation is fired after the response is received,
128
130
  # so UI plumbing can be exercised end-to-end without the proxy work.
129
131
  def send_messages_with_tools(messages, model:, tools:, max_tokens:, enable_caching: false, reasoning_effort: nil, on_chunk: nil)
132
+ api_model = Providers.resolve_api_model(base_url: @base_url, api_key: @api_key, model: model)
130
133
  caching_enabled = enable_caching && supports_prompt_caching?(model)
131
134
  cloned = deep_clone(messages)
132
135
 
@@ -141,13 +144,13 @@ module Clacky
141
144
  response =
142
145
  if bedrock?
143
146
  streaming_used = !on_chunk.nil?
144
- send_bedrock_request(cloned, model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
147
+ send_bedrock_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
145
148
  elsif anthropic_format?
146
149
  streaming_used = !on_chunk.nil?
147
- send_anthropic_request(cloned, model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
150
+ send_anthropic_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
148
151
  else
149
152
  streaming_used = !on_chunk.nil?
150
- send_openai_request(cloned, model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
153
+ send_openai_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk, capability_model: model)
151
154
  end
152
155
  t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
153
156
 
@@ -333,17 +336,20 @@ module Clacky
333
336
 
334
337
  # ── OpenAI request / response ─────────────────────────────────────────────
335
338
 
336
- def send_openai_request(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, on_chunk: nil)
339
+ def send_openai_request(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, on_chunk: nil, capability_model: nil)
337
340
  # Apply cache_control markers to messages when caching is enabled.
338
341
  # OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
339
342
  messages = apply_message_caching(messages) if caching_enabled
340
343
 
341
- # Vision support is resolved against the request's actual model (which may
342
- # differ from @model after a runtime switch or fallback override), so the
343
- # conversion layer strips image_url blocks for non-vision models.
344
+ # Vision support is resolved against the display model name, which is the
345
+ # key our capability table is declared with. `model` may be an
346
+ # endpoint-specific API id (e.g. Ark payg's "glm-5-2-260617") that the
347
+ # table can't match — so the caller passes the display name separately
348
+ # via capability_model to keep the vision judgement accurate.
349
+ cap_model = capability_model || model
344
350
  body = MessageFormat::OpenAI.build_request_body(
345
351
  messages, model, tools, max_tokens, caching_enabled,
346
- vision_supported: Providers.supports?(@provider_id, :vision, model_name: model),
352
+ vision_supported: Providers.supports?(@provider_id, :vision, model_name: cap_model),
347
353
  reasoning_effort: reasoning_effort
348
354
  )
349
355
  return send_openai_stream_request(body, on_chunk) if on_chunk