openclacky 1.5.13 → 1.5.14

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -1
@@ -31,6 +31,7 @@ module Clacky
31
31
  working_dir: config[:working_dir],
32
32
  mode: config[:mode],
33
33
  model: config[:model],
34
+ reasoning_effort: config[:reasoning_effort],
34
35
  theme: config[:theme]
35
36
  }
36
37
 
@@ -81,6 +82,7 @@ module Clacky
81
82
  working_dir: @config[:working_dir],
82
83
  mode: @config[:mode],
83
84
  model: @config[:model],
85
+ reasoning_effort: @config[:reasoning_effort],
84
86
  tasks: @tasks_count,
85
87
  cost: @total_cost
86
88
  )
@@ -124,6 +126,7 @@ module Clacky
124
126
  working_dir: @config[:working_dir],
125
127
  mode: @config[:mode],
126
128
  model: @config[:model],
129
+ reasoning_effort: @config[:reasoning_effort],
127
130
  tasks: @tasks_count,
128
131
  cost: @total_cost,
129
132
  cost_source: cost_source,
@@ -1271,6 +1274,7 @@ module Clacky
1271
1274
  "",
1272
1275
  theme.format_text("Commands:", :info),
1273
1276
  " #{theme.format_text("/model", :success)} - Quickly switch the current model",
1277
+ " #{theme.format_text("/think", :success)} - Set the thinking (reasoning) effort level",
1274
1278
  " #{theme.format_text("/config", :success)} - Configure models, API keys, settings",
1275
1279
  " #{theme.format_text("/goal", :success)} - Set a standing goal for autonomous work",
1276
1280
  " #{theme.format_text("/goal <text>", :dim)} Set a goal and start working toward it",
@@ -1803,6 +1807,7 @@ module Clacky
1803
1807
  working_dir: @config[:working_dir],
1804
1808
  mode: @config[:mode],
1805
1809
  model: @config[:model],
1810
+ reasoning_effort: @config[:reasoning_effort],
1806
1811
  tasks: @tasks_count,
1807
1812
  cost: @total_cost
1808
1813
  )
@@ -1871,14 +1876,15 @@ module Clacky
1871
1876
  }
1872
1877
  end
1873
1878
 
1874
- # Add action buttons
1875
- choices << { name: "─" * 50, disabled: true }
1876
- choices << { name: "[+] Add New Model", value: { action: :add } }
1879
+ # Add action buttons (sticky: always visible below the scrollable
1880
+ # model list, so a long model list can't push them off screen)
1881
+ choices << { name: "─" * 50, disabled: true, sticky: true }
1882
+ choices << { name: "[+] Add New Model", value: { action: :add }, sticky: true }
1877
1883
  if current_config.models.length > 0
1878
- choices << { name: "[*] Edit Current Model", value: { action: :edit } }
1879
- choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
1884
+ choices << { name: "[*] Edit Current Model", value: { action: :edit }, sticky: true }
1885
+ choices << { name: "[-] Delete Model", value: { action: :delete }, sticky: true } if current_config.models.length > 1
1880
1886
  end
1881
- choices << { name: "[X] Close", value: { action: :close } }
1887
+ choices << { name: "[X] Close", value: { action: :close }, sticky: true }
1882
1888
 
1883
1889
  # Show menu
1884
1890
  result = modal.show(
@@ -1903,7 +1909,7 @@ module Clacky
1903
1909
  if new_model
1904
1910
  # Determine anthropic_format based on provider
1905
1911
  # For Anthropic provider, use Anthropic API format
1906
- anthropic_format = new_model[:provider] == "anthropic"
1912
+ anthropic_format = new_model[:provider] == Clacky::Providers::ANTHROPIC_ID
1907
1913
 
1908
1914
  current_config.add_model(
1909
1915
  model: new_model[:model],
@@ -2121,6 +2127,37 @@ module Clacky
2121
2127
  result # Return selected task_id or nil
2122
2128
  end
2123
2129
 
2130
+ # Show a reasoning-effort picker for the `/think` command.
2131
+ # @param current_effort [String, nil] the agent's current effort level
2132
+ # @return [String, nil] the chosen level ("off"/"low"/.../"max") or nil if cancelled
2133
+ public def show_reasoning_effort_menu(current_effort)
2134
+ modal = Components::ModalComponent.new
2135
+
2136
+ descriptions = {
2137
+ "off" => "Provider default (no effort override)",
2138
+ "low" => "Light reasoning - fastest",
2139
+ "medium" => "Balanced reasoning",
2140
+ "high" => "Deep reasoning",
2141
+ "xhigh" => "Extra deep reasoning",
2142
+ "max" => "Maximum reasoning - slowest"
2143
+ }
2144
+
2145
+ active_index = nil
2146
+ choices = descriptions.keys.each_with_index.map do |level, idx|
2147
+ is_current = current_effort.nil? ? level == "off" : level == current_effort
2148
+ active_index = idx if is_current
2149
+ marker = is_current ? "● " : " "
2150
+ { name: "#{marker}#{level} - #{descriptions[level]}", value: level }
2151
+ end
2152
+
2153
+ modal.show(
2154
+ title: "Thinking Level - Set Reasoning Effort",
2155
+ choices: choices,
2156
+ initial_index: active_index,
2157
+ on_close: -> { @layout.rerender_all }
2158
+ )
2159
+ end
2160
+
2124
2161
  # Show form for editing a model
2125
2162
  # @param model [Hash, nil] Existing model hash or nil for new model
2126
2163
  # @return [Hash, nil] Updated model hash or nil if cancelled
@@ -299,7 +299,9 @@ module Clacky
299
299
  Zip::InputStream.open(StringIO.new(body)) do |zis|
300
300
  while (entry = zis.get_next_entry)
301
301
  size = entry.size ? " (#{entry.size} bytes)" : ""
302
- lines << "- #{entry.name}#{size}"
302
+ # rubyzip returns names as raw ASCII-8BIT bytes regardless of the zip's
303
+ # UTF-8 flag; convert so the listing never carries binary encoding.
304
+ lines << "- #{Clacky::Utils::Encoding.to_utf8(entry.name)}#{size}"
303
305
  end
304
306
  end
305
307
  lines.join("\n")
@@ -325,7 +327,7 @@ module Clacky
325
327
  tar.each do |entry|
326
328
  kind = entry.directory? ? "[dir] " : ""
327
329
  size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
328
- lines << "- #{kind}#{entry.full_name}#{size}"
330
+ lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
329
331
  end
330
332
  end
331
333
  end
@@ -339,7 +341,7 @@ module Clacky
339
341
  tar.each do |entry|
340
342
  kind = entry.directory? ? "[dir] " : ""
341
343
  size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
342
- lines << "- #{kind}#{entry.full_name}#{size}"
344
+ lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
343
345
  end
344
346
  end
345
347
  end
@@ -359,7 +361,7 @@ module Clacky
359
361
  found_tar = true
360
362
  kind = entry.directory? ? "[dir] " : ""
361
363
  size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
362
- lines << "- #{kind}#{entry.full_name}#{size}"
364
+ lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
363
365
  end
364
366
  end
365
367
  end
@@ -388,7 +390,8 @@ module Clacky
388
390
  end
389
391
  end
390
392
  lines = ["# GZIP Contents\n"]
391
- lines << "- Original filename: #{original_name || "(not recorded)"}"
393
+ display_name = original_name ? Clacky::Utils::Encoding.to_utf8(original_name) : "(not recorded)"
394
+ lines << "- Original filename: #{display_name}"
392
395
  lines << "- Compressed size: #{File.size(path)} bytes"
393
396
  lines << "- Uncompressed size: #{uncompressed} bytes#{uncompressed && uncompressed > 64 * 1024 * 1024 ? " (truncated)" : ""}"
394
397
  lines.join("\n")
@@ -406,7 +409,14 @@ module Clacky
406
409
  FileUtils.mkdir_p(UPLOAD_DIR)
407
410
  safe_name = File.basename(original_path.to_s).gsub(/[\/\:\*?"<>|\x00]/, "_")
408
411
  dest = File.join(UPLOAD_DIR, "#{SecureRandom.hex(8)}_#{safe_name}.preview.md")
409
- File.write(dest, content)
412
+ # File.write transcodes to the default external encoding and raises
413
+ # "\xE8 from ASCII-8BIT to UTF-8" on binary (ASCII-8BIT) content, which
414
+ # can leak in from archive entry names / external parsers.
415
+ text = content.to_s
416
+ unless text.encoding == ::Encoding::UTF_8 && text.valid_encoding?
417
+ text = Clacky::Utils::Encoding.to_utf8(text)
418
+ end
419
+ File.write(dest, text)
410
420
  dest
411
421
  end
412
422
 
@@ -540,7 +550,9 @@ module Clacky
540
550
  LOCAL_IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
541
551
  LOCAL_VIDEO_EXTENSIONS = %w[.mp4 .webm .mov].freeze
542
552
  LOCAL_AUDIO_EXTENSIONS = %w[.wav .mp3 .ogg .aac .flac .m4a].freeze
543
- LOCAL_MEDIA_EXTENSIONS = (LOCAL_IMAGE_EXTENSIONS + LOCAL_VIDEO_EXTENSIONS + LOCAL_AUDIO_EXTENSIONS).freeze
553
+ # .pdf: served by /api/local-image so the WebUI file viewer can embed it
554
+ # in an iframe (browser-native PDF rendering, no extra dependency).
555
+ LOCAL_MEDIA_EXTENSIONS = (LOCAL_IMAGE_EXTENSIONS + LOCAL_VIDEO_EXTENSIONS + LOCAL_AUDIO_EXTENSIONS + %w[.pdf]).freeze
544
556
 
545
557
  # Replace local image paths in markdown content with base64 data URLs.
546
558
  #
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "digest"
5
+ require_relative "environment_detector"
6
+
7
+ module Clacky
8
+ module Utils
9
+ # Detects installed macOS applications able to open a given file type.
10
+ #
11
+ # Builds an extension → apps index by scanning application bundles and
12
+ # parsing their Info.plist document declarations (CFBundleDocumentTypes),
13
+ # then applies a static supplement for well-known apps whose cross-format
14
+ # abilities are not declared in the plist — e.g. Keynote opens ppt/pptx
15
+ # without listing them. Non-macOS hosts always answer with an empty list.
16
+ module MacAppDetector
17
+ SEARCH_DIRS = [
18
+ "/Applications",
19
+ "/System/Applications",
20
+ File.expand_path("~/Applications")
21
+ ].freeze
22
+
23
+ # bundle name (without .app) => extensions it handles but does not
24
+ # declare in CFBundleDocumentTypes.
25
+ EXTRA_ABILITIES = {
26
+ "Keynote" => %w[key kth ppt pptx pot pps ppsx],
27
+ "Pages" => %w[pages doc docx dot rtf],
28
+ "Numbers" => %w[numbers xls xlsx csv],
29
+ "Preview" => %w[pdf png jpg jpeg gif tiff tif heic webp svg bmp],
30
+ "Microsoft PowerPoint" => %w[ppt pptx pot pps ppsx],
31
+ "Microsoft Word" => %w[doc docx dot rtf],
32
+ "Microsoft Excel" => %w[xls xlsx csv],
33
+ }.freeze
34
+
35
+ # @param ext [String] file extension, with or without leading dot
36
+ # @return [Array<Hash>] [{ "name" => ..., "path" => bundle path }]
37
+ def self.apps_for_ext(ext)
38
+ return [] unless Utils::EnvironmentDetector.os_type == :macos
39
+
40
+ ext = ext.to_s.downcase.sub(/\A\./, "")
41
+ return [] if ext.empty?
42
+
43
+ index[ext].sort_by { |a| a["name"] }
44
+ end
45
+
46
+ # Resolve an app name or bundle path to a scanned bundle path.
47
+ # @return [String, nil]
48
+ def self.app_path(name_or_path)
49
+ return nil unless Utils::EnvironmentDetector.os_type == :macos
50
+
51
+ all_apps[name_or_path.to_s] || all_apps.key(name_or_path.to_s)
52
+ end
53
+
54
+ # Return the application macOS would use to open `path` (the handler the
55
+ # plain `open` command would pick). Cached per file extension.
56
+ # @param path [String] absolute file path
57
+ # @return [Hash, nil] { "name" => ..., "path" => bundle path }
58
+ def self.default_app_for(path)
59
+ return nil unless Utils::EnvironmentDetector.os_type == :macos
60
+
61
+ ext = File.extname(path).downcase.sub(/\A\./, "")
62
+ return nil if ext.empty?
63
+
64
+ @default_app_cache ||= {}
65
+ return @default_app_cache[ext] if @default_app_cache.key?(ext)
66
+
67
+ bundle = default_app_bundle(path)
68
+ @default_app_cache[ext] =
69
+ if bundle && File.directory?(bundle)
70
+ { "name" => File.basename(bundle, ".app"), "path" => bundle }
71
+ end
72
+ end
73
+
74
+ # Query LaunchServices for the default handler of `path` via NSWorkspace.
75
+ # @return [String, nil] bundle path (…/*.app)
76
+ def self.default_app_bundle(path)
77
+ script = <<~JXA
78
+ (function() {
79
+ ObjC.import("AppKit");
80
+ var url = $.NSURL.fileURLWithPath(#{path.to_json});
81
+ var app = $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL(url);
82
+ return app ? app.path.js : "";
83
+ })()
84
+ JXA
85
+ out, _err, _status = Open3.capture3("osascript", "-l", "JavaScript", "-e", script)
86
+ result = out.strip
87
+ result.empty? ? nil : result
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ # Convert an application bundle icon (.icns) to a cached 64px PNG via the
93
+ # system `sips` tool. Used by the Web UI "open with" dropdown.
94
+ # @param bundle_path [String] application bundle path (…/*.app)
95
+ # @return [String, nil] path to the cached PNG file
96
+ def self.icon_png(bundle_path)
97
+ return nil unless Utils::EnvironmentDetector.os_type == :macos
98
+
99
+ bundle = bundle_path.to_s
100
+ return nil unless bundle.end_with?(".app") && File.directory?(bundle)
101
+
102
+ cache_dir = File.join(Dir.home, "Library", "Caches", "openclacky", "app-icons")
103
+ cache = File.join(cache_dir, Digest::MD5.hexdigest(bundle) + ".png")
104
+ return cache if File.exist?(cache)
105
+
106
+ info = read_plist(File.join(bundle, "Contents", "Info.plist"))
107
+ return nil unless info
108
+
109
+ icon = info["CFBundleIconFile"].to_s
110
+ return nil if icon.empty?
111
+ icon += ".icns" unless icon =~ /\.icns\z/i
112
+ icns = File.join(bundle, "Contents", "Resources", icon)
113
+ return nil unless File.exist?(icns)
114
+
115
+ require "fileutils"
116
+ FileUtils.mkdir_p(cache_dir)
117
+ ok = system("sips", "-s", "format", "png", "--resampleWidth", "64",
118
+ icns, "--out", cache, out: File::NULL, err: File::NULL)
119
+ return nil unless ok && File.exist?(cache)
120
+ cache
121
+ end
122
+
123
+ # Open a file with a specific installed application (macOS only).
124
+ # @param path [String] absolute file path
125
+ # @param app [String] app name or bundle path, must be a scanned app
126
+ # @return [Boolean, nil] system() result, nil when the app is unknown
127
+ def self.open_with(path, app)
128
+ bundle = app_path(app.to_s)
129
+ return nil unless bundle
130
+ system("open", "-a", bundle, path)
131
+ end
132
+
133
+ def self.all_apps
134
+ return @all_apps if defined?(@all_apps)
135
+
136
+ @all_apps = {}
137
+ SEARCH_DIRS.each do |dir|
138
+ next unless File.directory?(dir)
139
+ Dir.glob(File.join(dir, "*.app")).each do |bundle|
140
+ @all_apps[File.basename(bundle, ".app")] = bundle
141
+ end
142
+ end
143
+ @all_apps
144
+ end
145
+
146
+ def self.index
147
+ return @index if defined?(@index)
148
+
149
+ @index = Hash.new { |h, k| h[k] = [] }
150
+
151
+ all_apps.each do |name, bundle|
152
+ info = read_plist(File.join(bundle, "Contents", "Info.plist"))
153
+ next unless info
154
+ (info["CFBundleDocumentTypes"] || []).each do |dt|
155
+ (dt["CFBundleTypeExtensions"] || []).each do |e|
156
+ add(@index, e.to_s.downcase, name, bundle)
157
+ end
158
+ end
159
+ end
160
+
161
+ EXTRA_ABILITIES.each do |name, exts|
162
+ bundle = all_apps[name]
163
+ next unless bundle
164
+ exts.each { |e| add(@index, e, name, bundle) }
165
+ end
166
+
167
+ @index
168
+ end
169
+
170
+ def self.add(index, ext, name, bundle)
171
+ return if ext.empty?
172
+ list = index[ext]
173
+ list << { "name" => name, "path" => bundle } unless list.any? { |a| a["path"] == bundle }
174
+ end
175
+
176
+ def self.read_plist(path)
177
+ out, _err, _status = Open3.capture3("plutil", "-convert", "json", "-o", "-", path)
178
+ return nil if out.nil? || out.empty?
179
+ require "json"
180
+ JSON.parse(out)
181
+ rescue StandardError
182
+ nil
183
+ end
184
+ end
185
+ end
186
+ end
@@ -176,28 +176,40 @@ module Clacky
176
176
  # - "cache miss input" = regular prompt_tokens rate
177
177
  # - "cache hit input" = cache_read rate (DeepSeek has no separate cache-write charge)
178
178
  # - No tiered pricing (single rate regardless of context length)
179
- # Effective 2026-08-16 16:00 UTC DeepSeek switched to peak/off-peak billing
180
- # (off-peak = half of peak; peak = 01:00-04:00 & 06:00-10:00 UTC).
181
- # Effective 2026-08-23 00:00 Beijing time, weekends (Sat/Sun, Beijing
182
- # time) are billed entirely at off-peak rates regardless of hour.
183
- # Each entry carries legacy/peak/off_peak tiers; calculate_cost resolves
184
- # the active tier from the request time.
185
- "deepseek-v4-flash" => {
179
+ # Peak/off-peak billing (off-peak = half of peak; peak = 01:00-04:00 &
180
+ # 06:00-10:00 UTC). Weekends (Sat/Sun, Beijing time) are billed entirely
181
+ # at off-peak rates regardless of hour.
182
+ # v4-pro keeps its own rates until 2026-09-14, when it is routed to
183
+ # V4.1 Flash and billed at the flash rates (see flash_peak/flash_off_peak).
184
+ # Each entry carries peak/off_peak tiers; calculate_cost resolves the
185
+ # active tier from the request time.
186
+ # V4.1 Flash — new canonical model id. Native multimodal; the retired
187
+ # v4-flash / v4-flash-vision-exp ids are routed here for compatibility.
188
+ "deepseek-flash" => {
186
189
  deepseek: true,
187
- legacy: {
188
- input: { default: 0.14, over_200k: 0.14 }, # $0.14/MTok (pre-cutover flat)
189
- output: { default: 0.28, over_200k: 0.28 }, # $0.28/MTok
190
- cache: { write: 0.14, read: 0.0028 } # $0.0028/MTok cache hit
190
+ peak: {
191
+ input: { default: 0.30, over_200k: 0.30 },
192
+ output: { default: 1.20, over_200k: 1.20 },
193
+ cache: { write: 0.30, read: 0.006 }
191
194
  },
195
+ off_peak: {
196
+ input: { default: 0.15, over_200k: 0.15 },
197
+ output: { default: 0.60, over_200k: 0.60 },
198
+ cache: { write: 0.15, read: 0.003 }
199
+ }
200
+ },
201
+
202
+ "deepseek-v4-flash" => {
203
+ deepseek: true,
192
204
  peak: {
193
- input: { default: 0.44, over_200k: 0.44 }, # $0.44/MTok cache miss (peak)
194
- output: { default: 1.32, over_200k: 1.32 }, # $1.32/MTok
195
- cache: { write: 0.44, read: 0.014 } # $0.014/MTok cache hit
205
+ input: { default: 0.30, over_200k: 0.30 }, # $0.30/MTok cache miss (peak)
206
+ output: { default: 1.20, over_200k: 1.20 }, # $1.20/MTok
207
+ cache: { write: 0.30, read: 0.006 } # $0.006/MTok cache hit
196
208
  },
197
209
  off_peak: {
198
- input: { default: 0.22, over_200k: 0.22 }, # $0.22/MTok (half of peak)
199
- output: { default: 0.66, over_200k: 0.66 }, # $0.66/MTok
200
- cache: { write: 0.22, read: 0.007 } # $0.007/MTok cache hit
210
+ input: { default: 0.15, over_200k: 0.15 }, # $0.15/MTok (half of peak)
211
+ output: { default: 0.60, over_200k: 0.60 }, # $0.60/MTok
212
+ cache: { write: 0.15, read: 0.003 } # $0.003/MTok cache hit
201
213
  }
202
214
  },
203
215
 
@@ -205,30 +217,20 @@ module Clacky
205
217
  # as tokens per DeepSeek's image tokenization rules).
206
218
  "deepseek-v4-flash-vision-exp" => {
207
219
  deepseek: true,
208
- legacy: {
209
- input: { default: 0.14, over_200k: 0.14 },
210
- output: { default: 0.28, over_200k: 0.28 },
211
- cache: { write: 0.14, read: 0.0028 }
212
- },
213
220
  peak: {
214
- input: { default: 0.44, over_200k: 0.44 },
215
- output: { default: 1.32, over_200k: 1.32 },
216
- cache: { write: 0.44, read: 0.014 }
221
+ input: { default: 0.30, over_200k: 0.30 },
222
+ output: { default: 1.20, over_200k: 1.20 },
223
+ cache: { write: 0.30, read: 0.006 }
217
224
  },
218
225
  off_peak: {
219
- input: { default: 0.22, over_200k: 0.22 },
220
- output: { default: 0.66, over_200k: 0.66 },
221
- cache: { write: 0.22, read: 0.007 }
226
+ input: { default: 0.15, over_200k: 0.15 },
227
+ output: { default: 0.60, over_200k: 0.60 },
228
+ cache: { write: 0.15, read: 0.003 }
222
229
  }
223
230
  },
224
231
 
225
232
  "deepseek-v4-pro" => {
226
233
  deepseek: true,
227
- legacy: {
228
- input: { default: 0.435, over_200k: 0.435 }, # $0.435/MTok (pre-cutover flat)
229
- output: { default: 0.87, over_200k: 0.87 }, # $0.87/MTok
230
- cache: { write: 0.435, read: 0.003625 } # $0.003625/MTok cache hit
231
- },
232
234
  peak: {
233
235
  input: { default: 1.32, over_200k: 1.32 }, # $1.32/MTok cache miss (peak)
234
236
  output: { default: 3.96, over_200k: 3.96 }, # $3.96/MTok
@@ -238,6 +240,18 @@ module Clacky
238
240
  input: { default: 0.66, over_200k: 0.66 }, # $0.66/MTok (half of peak)
239
241
  output: { default: 1.98, over_200k: 1.98 }, # $1.98/MTok
240
242
  cache: { write: 0.66, read: 0.022 } # $0.022/MTok cache hit
243
+ },
244
+ # Routed to V4.1 Flash from 2026-09-14 12:00 Beijing time and billed
245
+ # at the flash rates.
246
+ flash_peak: {
247
+ input: { default: 0.30, over_200k: 0.30 },
248
+ output: { default: 1.20, over_200k: 1.20 },
249
+ cache: { write: 0.30, read: 0.006 }
250
+ },
251
+ flash_off_peak: {
252
+ input: { default: 0.15, over_200k: 0.15 },
253
+ output: { default: 0.60, over_200k: 0.60 },
254
+ cache: { write: 0.15, read: 0.003 }
241
255
  }
242
256
  },
243
257
 
@@ -484,53 +498,80 @@ module Clacky
484
498
  }
485
499
  },
486
500
 
487
- # GPT-5.6 flat-rate models (no breakpoint, single rate regardless of
488
- # context; *-pro variants are priced identically to the base tier).
489
- # Source: OpenAI list price ($/MTok) via OpenRouter model pages, incl.
490
- # the OpenAI-direct provider row (verified 2026-08-18). Cache write is
491
- # not published separately; it follows the GPT-5.5 convention (= input).
501
+ # GPT-5.6 via Bedrock's OpenAI-compatible endpoint (Global CRIS).
502
+ # Tiered at 272K input tokens (OpenAI's breakpoint) — same caveat as
503
+ # GPT-6 Astra below. USD per 1M tokens, source: llm_proxy bedrock_openai
504
+ # pricing. *-pro variants are priced identically to the base tier.
492
505
  "gpt-5.6-sol" => {
493
506
  input: {
494
- default: 2.50,
495
- over_200k: 2.50
507
+ default: 4.00,
508
+ over_200k: 8.00
496
509
  },
497
510
  output: {
498
- default: 15.00,
499
- over_200k: 15.00
511
+ default: 20.00,
512
+ over_200k: 30.00
500
513
  },
501
514
  cache: {
502
- write: 2.50,
503
- read: 0.25
515
+ write_default: 5.00,
516
+ write_over_200k: 10.00,
517
+ read_default: 0.40,
518
+ read_over_200k: 0.80
504
519
  }
505
520
  },
506
521
 
507
522
  "gpt-5.6-terra" => {
508
523
  input: {
509
524
  default: 2.00,
510
- over_200k: 2.00
525
+ over_200k: 4.00
511
526
  },
512
527
  output: {
513
528
  default: 12.00,
514
- over_200k: 12.00
529
+ over_200k: 18.00
515
530
  },
516
531
  cache: {
517
- write: 2.00,
518
- read: 0.20
532
+ write_default: 2.50,
533
+ write_over_200k: 5.00,
534
+ read_default: 0.20,
535
+ read_over_200k: 0.40
519
536
  }
520
537
  },
521
538
 
522
539
  "gpt-5.6-luna" => {
523
540
  input: {
524
541
  default: 0.20,
525
- over_200k: 0.20
542
+ over_200k: 0.40
526
543
  },
527
544
  output: {
528
545
  default: 1.20,
529
- over_200k: 1.20
546
+ over_200k: 1.80
530
547
  },
531
548
  cache: {
532
- write: 0.20,
533
- read: 0.02
549
+ write_default: 0.25,
550
+ write_over_200k: 0.50,
551
+ read_default: 0.02,
552
+ read_over_200k: 0.04
553
+ }
554
+ },
555
+
556
+ # GPT-6 Astra via Bedrock's OpenAI-compatible endpoint (Global CRIS).
557
+ # Tiered at 272K input tokens (OpenAI's breakpoint, not the global 200K)
558
+ # — the 200K–272K band is slightly over-estimated, same caveat as
559
+ # GPT-5.5/5.4 below. USD per 1M tokens, source: llm_proxy bedrock_openai
560
+ # pricing.
561
+ "gpt-6-astra" => {
562
+ input: {
563
+ default: 10.00,
564
+ over_200k: 20.00
565
+ },
566
+ output: {
567
+ default: 50.00,
568
+ over_200k: 75.00
569
+ },
570
+ cache: {
571
+ write_default: 12.50,
572
+ write_over_200k: 25.00,
573
+ read_default: 1.00,
574
+ read_over_200k: 2.00
534
575
  }
535
576
  },
536
577
 
@@ -854,9 +895,9 @@ module Clacky
854
895
  # Costs for prompts between 200K–272K will be slightly over-estimated.
855
896
  TIERED_PRICING_THRESHOLD = 200_000
856
897
 
857
- # DeepSeek switched from flat legacy rates to peak/off-peak billing at
858
- # 2026-08-17 00:00 Beijing time (= 2026-08-16 16:00 UTC).
859
- DEEPSEEK_PEAK_PRICING_START = Time.utc(2026, 8, 16, 16, 0, 0).freeze
898
+ # v4-pro is routed to V4.1 Flash and billed at the flash rates from
899
+ # 2026-09-14 12:00 Beijing time (= 04:00 UTC).
900
+ DEEPSEEK_V4_PRO_FLASH_START = Time.utc(2026, 9, 14, 4, 0, 0)
860
901
 
861
902
  class << self
862
903
  # Calculate cost for the given model and usage
@@ -1000,6 +1041,9 @@ module Clacky
1000
1041
  "deepseek-v4-flash-vision-exp"
1001
1042
  when /deepseek-v4-flash/i, /deepseek.*v4.*flash/i
1002
1043
  "deepseek-v4-flash"
1044
+ # V4.1 Flash — new canonical id; native multimodal.
1045
+ when /deepseek-flash/i
1046
+ "deepseek-flash"
1003
1047
  # Legacy aliases: deepseek-chat and deepseek-reasoner are being
1004
1048
  # deprecated on 2026-07-24 and map to deepseek-v4-flash's
1005
1049
  # non-thinking / thinking modes respectively. Bill at flash rates.
@@ -1102,11 +1146,13 @@ module Clacky
1102
1146
  # anchored rules above would otherwise miss it) and the "-pro"
1103
1147
  # suffix (pro is priced identically to the base tier). Batch ids
1104
1148
  # (":batch") stay unmatched - they bill at half price.
1105
- when %r{^(openai/)?gpt-?5[\.-]?6[\.-]?sol(-pro)?$}i
1149
+ when /^(?:abs-|us\.openai\.|global\.openai\.|openai\/)?gpt-?6[.-]?astra$/i
1150
+ "gpt-6-astra"
1151
+ when %r{^(?:abs-|global\.openai\.|openai/)?gpt-?5[\.-]?6[\.-]?sol(-pro)?$}i
1106
1152
  "gpt-5.6-sol"
1107
- when %r{^(openai/)?gpt-?5[\.-]?6[\.-]?terra(-pro)?$}i
1153
+ when %r{^(?:abs-|global\.openai\.|openai/)?gpt-?5[\.-]?6[\.-]?terra(-pro)?$}i
1108
1154
  "gpt-5.6-terra"
1109
- when %r{^(openai/)?gpt-?5[\.-]?6[\.-]?luna(-pro)?$}i
1155
+ when %r{^(?:abs-|global\.openai\.|openai/)?gpt-?5[\.-]?6[\.-]?luna(-pro)?$}i
1110
1156
  "gpt-5.6-luna"
1111
1157
  when /^gpt-?5\.?5$/i, /^gpt-?5[\.-]?5$/i
1112
1158
  "gpt-5.5"
@@ -1163,17 +1209,19 @@ module Clacky
1163
1209
  cache_cost
1164
1210
  end
1165
1211
 
1166
- # Resolve a DeepSeek pricing entry (which holds legacy/peak/off_peak
1167
- # tiers) to the single tier that applies at the given time.
1212
+ # Resolve a DeepSeek pricing entry (which holds peak/off_peak tiers) to
1213
+ # the single tier that applies at the given time.
1168
1214
  def resolve_deepseek_tier(pricing, now)
1169
- if now < DEEPSEEK_PEAK_PRICING_START
1170
- pricing[:legacy]
1171
- elsif deepseek_weekend?(now)
1215
+ # v4-pro migrates to V4.1 Flash on 2026-09-14; its entry carries
1216
+ # flash_peak/flash_off_peak.
1217
+ if pricing.key?(:flash_peak) && now >= DEEPSEEK_V4_PRO_FLASH_START
1218
+ return deepseek_weekend?(now) || !deepseek_peak_hour?(now) ? pricing[:flash_off_peak] : pricing[:flash_peak]
1219
+ end
1220
+
1221
+ if deepseek_weekend?(now) || !deepseek_peak_hour?(now)
1172
1222
  pricing[:off_peak]
1173
- elsif deepseek_peak_hour?(now)
1174
- pricing[:peak]
1175
1223
  else
1176
- pricing[:off_peak]
1224
+ pricing[:peak]
1177
1225
  end
1178
1226
  end
1179
1227