openclacky 1.0.1 → 1.0.2

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.
@@ -19,7 +19,7 @@ require 'find'
19
19
  class ZipSkillInstaller
20
20
  ZIP_URL_PATTERN = %r{^https?://.+\.zip(\?.*)?$}i
21
21
 
22
- def initialize(zip_source, skill_name: nil, target_dir: nil)
22
+ def initialize(zip_source, skill_name: nil, target_dir: nil, skip_if_exists: false)
23
23
  @zip_source = zip_source
24
24
  @local_path = local_zip_path?(zip_source)
25
25
  # skill_name can be provided explicitly (e.g. slug from the store API).
@@ -27,45 +27,72 @@ class ZipSkillInstaller
27
27
  # "ui-ux-pro-max-1.0.0.zip" → "ui-ux-pro-max".
28
28
  @skill_name = skill_name || infer_skill_name(zip_source)
29
29
  @target_dir = target_dir || File.join(Dir.home, '.clacky', 'skills')
30
+ # When true, existing skill directories are preserved and the install for
31
+ # that specific skill is skipped (recorded in @skipped_skills).
32
+ # Default false keeps the legacy "overwrite" behaviour for `install`.
33
+ @skip_if_exists = skip_if_exists
34
+ # Suppresses user-facing puts for programmatic callers (set by `perform`).
35
+ @silent = false
30
36
  @installed_skills = []
31
- @errors = []
37
+ @skipped_skills = []
38
+ @errors = []
32
39
  end
33
40
 
34
- # Main installation entry point.
41
+ # Programmatic entry point for library-style callers (e.g. onboard pre-install).
42
+ #
43
+ # Unlike `install`, this method:
44
+ # - does NOT print user-facing output
45
+ # - does NOT call `exit` on failure (raises instead)
46
+ # - returns a result hash: { installed: [...], skipped: [...], errors: [...] }
47
+ #
48
+ # The caller is responsible for rendering feedback and deciding whether any
49
+ # error is fatal.
50
+ def perform
51
+ @silent = true
52
+ do_install
53
+ { installed: @installed_skills, skipped: @skipped_skills, errors: @errors }
54
+ end
55
+
56
+ # Main installation entry point (CLI). Prints progress, prints a final
57
+ # report, and calls `exit` on failure. Use `perform` for programmatic use.
35
58
  def install
59
+ do_install
60
+ report_results
61
+ rescue ArgumentError => e
62
+ puts "Error: #{e.message}"
63
+ exit 1
64
+ rescue StandardError => e
65
+ puts "Error: Installation failed: #{e.message}"
66
+ exit 1
67
+ end
68
+
69
+ # Shared core used by both `install` (CLI) and `perform` (library).
70
+ # Raises on invalid input; the caller decides how to surface errors.
71
+ private def do_install
36
72
  if @local_path
37
73
  # Install directly from a local zip file — no download needed.
38
- # Expand tilde in path (e.g. ~/Downloads/skill.zip)
74
+ # Expand tilde in path (e.g. ~/Downloads/skill.zip).
39
75
  expanded = File.expand_path(@zip_source)
40
- raise ArgumentError, "File not found: #{@zip_source}" unless File.exist?(expanded)
41
- raise ArgumentError, "Not a zip file: #{@zip_source}" unless expanded.end_with?('.zip')
76
+ raise ArgumentError, "File not found: #{@zip_source}" unless File.exist?(expanded)
77
+ raise ArgumentError, "Not a zip file: #{@zip_source}" unless expanded.end_with?('.zip')
42
78
 
43
79
  Dir.mktmpdir('clacky-zip-') do |tmpdir|
44
80
  extract_zip(expanded, tmpdir)
45
- extracted_dir = File.join(tmpdir, 'extracted')
46
- discover_and_install_skills(extracted_dir)
81
+ discover_and_install_skills(File.join(tmpdir, 'extracted'))
47
82
  end
48
83
  else
49
84
  # Install from a remote URL.
50
85
  unless valid_zip_url?
51
- raise ArgumentError, "Invalid zip source: #{@zip_source}\nProvide an http(s) URL ending with .zip, or an absolute path to a local zip file."
86
+ raise ArgumentError, "Invalid zip source: #{@zip_source}\n" \
87
+ "Provide an http(s) URL ending with .zip, or an absolute path to a local zip file."
52
88
  end
53
89
 
54
90
  Dir.mktmpdir('clacky-zip-') do |tmpdir|
55
91
  zip_path = download_zip(tmpdir)
56
92
  extract_zip(zip_path, tmpdir)
57
- extracted_dir = File.join(tmpdir, 'extracted')
58
- discover_and_install_skills(extracted_dir)
93
+ discover_and_install_skills(File.join(tmpdir, 'extracted'))
59
94
  end
60
95
  end
61
-
62
- report_results
63
- rescue ArgumentError => e
64
- puts "❌ #{e.message}"
65
- exit 1
66
- rescue StandardError => e
67
- puts "❌ Installation failed: #{e.message}"
68
- exit 1
69
96
  end
70
97
 
71
98
  # Return true if the source looks like a local file path (absolute or relative ending in .zip).
@@ -93,8 +120,10 @@ class ZipSkillInstaller
93
120
 
94
121
  # Download the zip file to tmpdir and return its local path.
95
122
  private def download_zip(tmpdir)
96
- puts "⬇️ Downloading skill package..."
97
- puts " #{@zip_source}"
123
+ unless @silent
124
+ puts "Downloading skill package..."
125
+ puts " #{@zip_source}"
126
+ end
98
127
 
99
128
  zip_path = File.join(tmpdir, 'skill.zip')
100
129
  uri = URI.parse(@zip_source)
@@ -129,7 +158,7 @@ class ZipSkillInstaller
129
158
 
130
159
  # Extract the zip archive into <tmpdir>/extracted/.
131
160
  private def extract_zip(zip_path, tmpdir)
132
- puts "📂 Extracting package..."
161
+ puts "Extracting package..." unless @silent
133
162
  extracted_dir = File.join(tmpdir, 'extracted')
134
163
  FileUtils.mkdir_p(extracted_dir)
135
164
 
@@ -184,7 +213,11 @@ class ZipSkillInstaller
184
213
  target_path = File.join(@target_dir, name)
185
214
 
186
215
  if File.exist?(target_path)
187
- puts "♻️ Skill '#{name}' already exists — overwriting..."
216
+ if @skip_if_exists
217
+ @skipped_skills << { name: name, path: target_path, reason: 'already exists' }
218
+ return
219
+ end
220
+ puts "Skill '#{name}' already exists — overwriting..." unless @silent
188
221
  FileUtils.rm_rf(target_path)
189
222
  end
190
223
 
@@ -218,7 +251,7 @@ class ZipSkillInstaller
218
251
  puts "\n" + "=" * 60
219
252
 
220
253
  if @installed_skills.empty?
221
- puts "No skills were installed."
254
+ puts "No skills were installed."
222
255
  if @errors.any?
223
256
  puts "\nErrors:"
224
257
  @errors.each { |e| puts " • #{e}" }
@@ -226,7 +259,7 @@ class ZipSkillInstaller
226
259
  exit 1
227
260
  end
228
261
 
229
- puts "Installation complete!"
262
+ puts "Installation complete!"
230
263
  puts "\nInstalled #{@installed_skills.size} skill(s):\n\n"
231
264
  @installed_skills.each do |skill|
232
265
  puts " ✓ #{skill[:name]}"
@@ -236,7 +269,7 @@ class ZipSkillInstaller
236
269
  end
237
270
 
238
271
  if @errors.any?
239
- puts "⚠️ Warnings:"
272
+ puts "Warnings:"
240
273
  @errors.each { |e| puts " • #{e}" }
241
274
  puts
242
275
  end
@@ -125,6 +125,15 @@ module Clacky
125
125
  "api" => "openai-completions",
126
126
  "default_model" => "MiniMax-M2.7",
127
127
  "models" => ["MiniMax-M2.5", "MiniMax-M2.7"],
128
+ # MiniMax operates two regional endpoints with identical APIs & model
129
+ # lineup — mainland China (.com) and international (.io). Listing both
130
+ # lets find_by_base_url identify either one as provider "minimax",
131
+ # so capability checks (vision=false) fire correctly regardless of
132
+ # which endpoint the user configured.
133
+ "endpoint_variants" => [
134
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
135
+ { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
136
+ ].freeze,
128
137
  # MiniMax M2.x does not support multimodal/vision input on this endpoint.
129
138
  "capabilities" => { "vision" => false }.freeze,
130
139
  "website_url" => "https://www.minimaxi.com/user-center/basic-information/interface-key"
@@ -136,6 +145,17 @@ module Clacky
136
145
  "api" => "openai-completions",
137
146
  "default_model" => "kimi-k2.6",
138
147
  "models" => ["kimi-k2.6", "kimi-k2.5"],
148
+ # Moonshot operates two regional endpoints with identical APIs & model
149
+ # lineup — mainland China (.cn) and international (.ai). Kimi does not
150
+ # distinguish pay-as-you-go vs coding-plan at the base_url level, so
151
+ # only two variants are needed. Listing both here lets find_by_base_url
152
+ # identify either one as provider "kimi", so downstream capability
153
+ # checks, fallback chains, and provider-specific behaviours work
154
+ # regardless of which endpoint the user configured.
155
+ "endpoint_variants" => [
156
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
157
+ { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
158
+ ].freeze,
139
159
  # k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
140
160
  "capabilities" => { "vision" => true }.freeze,
141
161
  "website_url" => "https://platform.moonshot.cn/console/api-keys"
@@ -192,11 +212,26 @@ module Clacky
192
212
  }.freeze,
193
213
 
194
214
  "glm" => {
195
- "name" => "GLM (ZhipuAI)",
215
+ "name" => "GLM (Z.ai / Zhipu)",
196
216
  "base_url" => "https://open.bigmodel.cn/api/paas/v4",
197
217
  "api" => "openai-completions",
198
218
  "default_model" => "glm-5.1",
199
219
  "models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
220
+ # Zhipu / Z.ai expose four functionally-equivalent endpoints:
221
+ # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
222
+ # each with a general-billing and a Coding-Plan subpath. They share the
223
+ # same model lineup & identical capability profile, so a single preset
224
+ # with endpoint_variants is the right shape — one source of truth for
225
+ # vision/model_capabilities, four URLs recognised by find_by_base_url.
226
+ # Without this, users pointing at api.z.ai or the /coding/ path fell
227
+ # through to the conservative "assume vision=true" default and got
228
+ # hallucinated image descriptions on text-only GLM models (C-5563).
229
+ "endpoint_variants" => [
230
+ { "label" => "Mainland · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mainland_cn_payg", "base_url" => "https://open.bigmodel.cn/api/paas/v4", "region" => "cn" }.freeze,
231
+ { "label" => "Mainland · Coding Plan", "label_key" => "settings.models.baseurl.variant.mainland_cn_coding", "base_url" => "https://open.bigmodel.cn/api/coding/paas/v4", "region" => "cn" }.freeze,
232
+ { "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg", "base_url" => "https://api.z.ai/api/paas/v4", "region" => "intl" }.freeze,
233
+ { "label" => "International · Coding Plan", "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4", "region" => "intl" }.freeze
234
+ ].freeze,
200
235
  # GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
201
236
  "capabilities" => { "vision" => false }.freeze,
202
237
  "model_capabilities" => {
@@ -376,14 +411,33 @@ module Clacky
376
411
  # Find provider ID by base URL.
377
412
  # Matches if the given URL starts with the provider's base_url (after normalisation),
378
413
  # so both exact matches and sub-path variants (e.g. "/v1") are recognised.
414
+ #
415
+ # Also scans `endpoint_variants` (when present) so providers that operate
416
+ # multiple regional / billing-plan endpoints under the same identity
417
+ # (e.g. GLM on open.bigmodel.cn + api.z.ai, MiniMax on .com + .io) are
418
+ # all recognised as that single provider — one capability definition,
419
+ # N entry URLs. Without this, users configured with a non-default
420
+ # variant fall back to the "unknown provider" path and miss capability
421
+ # enforcement (see C-5563).
379
422
  # @param base_url [String] The base URL to look up
380
423
  # @return [String, nil] The provider ID or nil if not found
381
424
  def find_by_base_url(base_url)
382
425
  return nil if base_url.nil? || base_url.empty?
383
426
  normalized = base_url.to_s.chomp("/")
384
427
  PRESETS.find do |_id, preset|
385
- preset_base = preset["base_url"].to_s.chomp("/")
386
- normalized == preset_base || normalized.start_with?("#{preset_base}/")
428
+ # Collect every URL this preset claims: the canonical base_url plus
429
+ # any declared endpoint_variants. Dedup so the canonical one showing
430
+ # up in both lists doesn't change behaviour.
431
+ candidates = [preset["base_url"]]
432
+ variants = preset["endpoint_variants"]
433
+ if variants.is_a?(Array)
434
+ variants.each { |v| candidates << v["base_url"] if v.is_a?(Hash) }
435
+ end
436
+ candidates.compact.uniq.any? do |candidate|
437
+ preset_base = candidate.to_s.chomp("/")
438
+ next false if preset_base.empty?
439
+ normalized == preset_base || normalized.start_with?("#{preset_base}/")
440
+ end
387
441
  end&.first
388
442
  end
389
443
 
@@ -166,6 +166,20 @@ module Clacky
166
166
  # @param event [Hash] Parsed message event
167
167
  # @return [void]
168
168
  def handle_message_event(event)
169
+ # In group chats, only respond when the bot is explicitly @-mentioned.
170
+ # Private chats always respond.
171
+ # Fail closed: if the bot's own open_id cannot be fetched (API error,
172
+ # bad credentials, etc.), drop group messages instead of responding to
173
+ # every message and spamming the group.
174
+ if event[:chat_type] == :group
175
+ bot_id = @bot.bot_open_id
176
+ if bot_id.nil?
177
+ Clacky::Logger.warn("[feishu] bot_open_id unavailable; dropping group message to avoid spam")
178
+ return
179
+ end
180
+ return unless Array(event[:mentioned_open_ids]).include?(bot_id)
181
+ end
182
+
169
183
  allowed_users = @config[:allowed_users]
170
184
  if allowed_users && !allowed_users.empty?
171
185
  return unless allowed_users.include?(event[:user_id])
@@ -226,6 +226,16 @@ module Clacky
226
226
  }.join
227
227
  end
228
228
 
229
+ # Get this bot's own open_id (cached, fetched lazily on first use).
230
+ # Used to detect @bot mentions in group chats.
231
+ # @return [String, nil] bot open_id, or nil if the API call fails
232
+ def bot_open_id
233
+ @bot_open_id ||= get("/open-apis/bot/v3/info").dig("bot", "open_id")
234
+ rescue => e
235
+ Clacky::Logger.warn("[feishu] Failed to fetch bot_open_id: #{e.message}")
236
+ nil
237
+ end
238
+
229
239
  # Get tenant access token (cached)
230
240
  # @return [String] Access token
231
241
  def tenant_access_token
@@ -98,6 +98,7 @@ module Clacky
98
98
  message_id: message_id,
99
99
  timestamp: timestamp,
100
100
  chat_type: chat_type,
101
+ mentioned_open_ids: Array(message["mentions"]).filter_map { |m| m.dig("id", "open_id") },
101
102
  raw: @data
102
103
  }
103
104
  rescue JSON::ParserError
@@ -27,8 +27,14 @@ module Clacky
27
27
  # @param run_agent_task [Proc] (session_id, agent, &task) — from HttpServer
28
28
  # @param interrupt_session [Proc] (session_id) — from HttpServer
29
29
  # @param channel_config [Clacky::ChannelConfig]
30
- # @param binding_mode [:user | :chat] how to map IM identities to sessions
31
- def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :user)
30
+ # @param binding_mode [:user | :chat | :chat_user] how to map IM identities to sessions.
31
+ # :chat_user (default) one session per (chat, user) pair. Most natural:
32
+ # private chat = that user's session; in a group each
33
+ # user has their own session; the same user across
34
+ # different groups keeps those contexts separate.
35
+ # :chat — one session per chat (all users in a group share it).
36
+ # :user — one session per user (merges DMs and all groups).
37
+ def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat_user)
32
38
  @registry = session_registry
33
39
  @session_builder = session_builder
34
40
  @run_agent_task = run_agent_task
@@ -354,8 +360,10 @@ module Clacky
354
360
  def channel_key(event)
355
361
  platform = event[:platform].to_s
356
362
  case @binding_mode
357
- when :chat then "#{platform}:chat:#{event[:chat_id]}"
358
- else "#{platform}:user:#{event[:user_id]}"
363
+ when :chat then "#{platform}:chat:#{event[:chat_id]}"
364
+ when :user then "#{platform}:user:#{event[:user_id]}"
365
+ else # :chat_user (default)
366
+ "#{platform}:chat:#{event[:chat_id]}:user:#{event[:user_id]}"
359
367
  end
360
368
  end
361
369
 
@@ -33,9 +33,15 @@ module Clacky
33
33
 
34
34
  # Update the reply context for the current inbound message.
35
35
  # Called at the start of each route_message so replies are threaded correctly.
36
- # @param event [Hash] inbound event with :message_id
36
+ # Also updates chat_id a session may span multiple chats (e.g. same user
37
+ # in both a direct message and a group), and each inbound event dictates
38
+ # where outbound replies should be routed.
39
+ # @param event [Hash] inbound event with :message_id and :chat_id
37
40
  def update_message_context(event)
38
- @mutex.synchronize { @message_id = event[:message_id] }
41
+ @mutex.synchronize do
42
+ @message_id = event[:message_id]
43
+ @chat_id = event[:chat_id] if event[:chat_id]
44
+ end
39
45
  end
40
46
 
41
47
  # === Output display ===
@@ -2706,12 +2706,16 @@ module Clacky
2706
2706
  def api_list_providers(res)
2707
2707
  providers = Clacky::Providers::PRESETS.map do |id, preset|
2708
2708
  {
2709
- id: id,
2710
- name: preset["name"],
2711
- base_url: preset["base_url"],
2712
- default_model: preset["default_model"],
2713
- models: preset["models"] || [],
2714
- website_url: preset["website_url"]
2709
+ id: id,
2710
+ name: preset["name"],
2711
+ base_url: preset["base_url"],
2712
+ default_model: preset["default_model"],
2713
+ models: preset["models"] || [],
2714
+ # Frontend uses this to render a Base URL dropdown (regional /
2715
+ # billing-plan variants) when present. Absent for single-endpoint
2716
+ # providers — UI renders a plain text input in that case.
2717
+ endpoint_variants: preset["endpoint_variants"],
2718
+ website_url: preset["website_url"]
2715
2719
  }
2716
2720
  end
2717
2721
  json_response(res, 200, { providers: providers })
@@ -141,26 +141,14 @@ module Clacky
141
141
  FileRef.new(name: name, type: :image, original_path: path)
142
142
 
143
143
  when ".csv"
144
- # CSV is plain text — read directly, no external parser needed.
145
- # Try UTF-8 first, then GBK (common in Chinese-origin CSV), then binary with replacement.
146
- begin
147
- text = read_text_with_encoding_fallback(path)
148
- preview_path = save_preview(text, path)
149
- FileRef.new(name: name, type: :csv, original_path: path, preview_path: preview_path)
150
- rescue => e
151
- FileRef.new(name: name, type: :csv, original_path: path, parse_error: e.message)
152
- end
144
+ # CSV is plain text — the file itself IS the preview. No parser, no copy.
145
+ # FileReader handles encoding fallback via safe_utf8 when it reads the file.
146
+ FileRef.new(name: name, type: :csv, original_path: path, preview_path: path)
153
147
 
154
148
  when *TEXT_PREVIEW_EXTENSIONS
155
- # Markdown / plain text: the file itself IS the preview.
156
- # No parser needed — just copy through (with encoding normalisation).
157
- begin
158
- text = read_text_with_encoding_fallback(path)
159
- preview_path = save_preview(text, path)
160
- FileRef.new(name: name, type: :text, original_path: path, preview_path: preview_path)
161
- rescue => e
162
- FileRef.new(name: name, type: :text, original_path: path, parse_error: e.message)
163
- end
149
+ # Markdown / plain text / log: the file itself IS the preview.
150
+ # No parser needed, no tmpdir copy — just point preview_path at the original.
151
+ FileRef.new(name: name, type: :text, original_path: path, preview_path: path)
164
152
 
165
153
  else
166
154
  result = Utils::ParserManager.parse(path)
@@ -397,7 +385,14 @@ module Clacky
397
385
  end
398
386
 
399
387
  def self.save_preview(content, original_path)
400
- dest = "#{original_path}.preview.md"
388
+ # Always write previews to a tmpdir-based path to avoid polluting the
389
+ # user's working directory with .preview.md sidecar files.
390
+ # Use the same UPLOAD_DIR that uploaded files live in; for on-disk files
391
+ # outside that dir (e.g. project files opened by file_reader), we still
392
+ # land in UPLOAD_DIR so the user's tree stays clean.
393
+ FileUtils.mkdir_p(UPLOAD_DIR)
394
+ safe_name = File.basename(original_path.to_s).gsub(/[\/\:\*?"<>|\x00]/, "_")
395
+ dest = File.join(UPLOAD_DIR, "#{SecureRandom.hex(8)}_#{safe_name}.preview.md")
401
396
  File.write(dest, content)
402
397
  dest
403
398
  end
@@ -413,26 +408,6 @@ module Clacky
413
408
  base.empty? ? 'upload' : base
414
409
  end
415
410
 
416
- # Read a text file with automatic encoding detection.
417
- # Tries UTF-8, then GBK (common for Chinese-origin CSV/text files), then
418
- # falls back to binary read with invalid byte replacement.
419
- def self.read_text_with_encoding_fallback(path)
420
- # Try UTF-8 first (most common, fastest path)
421
- raw = File.binread(path)
422
- utf8 = raw.dup.force_encoding("UTF-8")
423
- return utf8.encode("UTF-8") if utf8.valid_encoding?
424
-
425
- # Try GBK (GB2312 superset — common in Chinese Windows/Excel exports)
426
- begin
427
- return raw.encode("UTF-8", "GBK", invalid: :replace, undef: :replace, replace: "?")
428
- rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
429
- # fall through
430
- end
431
-
432
- # Last resort: binary read with replacement characters
433
- raw.encode("UTF-8", "binary", invalid: :replace, undef: :replace, replace: "?")
434
- end
435
-
436
411
  # Detect the actual image MIME type from raw binary data by inspecting
437
412
  # magic bytes, ignoring the file extension. Falls back to extension-based
438
413
  # detection when magic bytes don't match any known format.
@@ -549,7 +524,6 @@ module Clacky
549
524
  end
550
525
 
551
526
  private_class_method :parse_zip_listing, :parse_tar_listing, :save_preview, :sanitize_filename,
552
- :read_text_with_encoding_fallback,
553
527
  :downscale_png_chunky, :downscale_via_cli
554
528
  end
555
529
  end
@@ -279,6 +279,79 @@ module Clacky
279
279
  }
280
280
  },
281
281
 
282
+ # GLM (Zhipu / Z.ai) — USD per 1M tokens.
283
+ # Source: https://docs.z.ai/guides/overview/pricing (Z.ai international).
284
+ # Pricing policy: we always bill at the Z.ai international flat rate,
285
+ # regardless of which endpoint (mainland bigmodel.cn vs intl z.ai) the
286
+ # user configured. Rationale:
287
+ # 1. Mainland GLM uses tiered pricing (≤32K / >32K / >128K) where the
288
+ # >32K tier is hit by the vast majority of real requests, and is
289
+ # actually a few RMB cheaper than Z.ai's flat rate — displaying the
290
+ # (slightly higher) Z.ai rate gives users a "displayed ≤ actual"
291
+ # experience which is psychologically safer than the reverse.
292
+ # 2. Single flat rate keeps the table shape consistent with every
293
+ # other provider here (no special-case tier logic for just GLM).
294
+ # Cache-write: same convention as DeepSeek/Kimi — OpenAI-compatible
295
+ # endpoints don't charge separately for cache writes (Z.ai's page lists
296
+ # "Cached Input Storage: Limited-time Free"), so bill writes at the
297
+ # regular input miss rate for safe "displayed ≤ actual" behaviour.
298
+ "glm-5.1" => {
299
+ input: { default: 1.40, over_200k: 1.40 },
300
+ output: { default: 4.40, over_200k: 4.40 },
301
+ cache: { write: 1.40, read: 0.26 }
302
+ },
303
+
304
+ "glm-5" => {
305
+ input: { default: 1.00, over_200k: 1.00 },
306
+ output: { default: 3.20, over_200k: 3.20 },
307
+ cache: { write: 1.00, read: 0.20 }
308
+ },
309
+
310
+ "glm-5-turbo" => {
311
+ input: { default: 1.20, over_200k: 1.20 },
312
+ output: { default: 4.00, over_200k: 4.00 },
313
+ cache: { write: 1.20, read: 0.24 }
314
+ },
315
+
316
+ # GLM-5V-Turbo is the multimodal sibling of GLM-5-Turbo (vision capable,
317
+ # see providers.rb model_capabilities override). Same input/output rate
318
+ # as 5-Turbo per Z.ai's Vision Models table.
319
+ "glm-5v-turbo" => {
320
+ input: { default: 1.20, over_200k: 1.20 },
321
+ output: { default: 4.00, over_200k: 4.00 },
322
+ cache: { write: 1.20, read: 0.24 }
323
+ },
324
+
325
+ "glm-4.7" => {
326
+ input: { default: 0.60, over_200k: 0.60 },
327
+ output: { default: 2.20, over_200k: 2.20 },
328
+ cache: { write: 0.60, read: 0.11 }
329
+ },
330
+
331
+ # MiniMax — USD per 1M tokens.
332
+ # Source: https://platform.minimaxi.com (Pay-as-You-Go).
333
+ # MiniMax pricing is identical across mainland (.com) and international
334
+ # (.io) endpoints, verified by the team. Same cache-write convention as
335
+ # DeepSeek/Kimi/GLM: bill writes at the input miss rate (OpenAI-compatible
336
+ # usage responses from MiniMax don't reliably carry a separate
337
+ # cache_creation_input_tokens field, so a distinct write rate would be
338
+ # dead code in practice).
339
+ # Note: providers.rb uses the capitalised "MiniMax-M2.x" model id, but
340
+ # the pricing table keys are lowercased to stay consistent with the
341
+ # rest of this file; normalize_model_name() lowercases incoming model
342
+ # names before lookup.
343
+ "minimax-m2.5" => {
344
+ input: { default: 0.30, over_200k: 0.30 },
345
+ output: { default: 1.20, over_200k: 1.20 },
346
+ cache: { write: 0.30, read: 0.03 }
347
+ },
348
+
349
+ "minimax-m2.7" => {
350
+ input: { default: 0.30, over_200k: 0.30 },
351
+ output: { default: 1.20, over_200k: 1.20 },
352
+ cache: { write: 0.30, read: 0.06 }
353
+ },
354
+
282
355
  }.freeze
283
356
 
284
357
  # Threshold for tiered pricing (200K tokens)
@@ -418,6 +491,28 @@ module Clacky
418
491
  "kimi-k2.5"
419
492
  when /^kimi-k2\.?6$/i
420
493
  "kimi-k2.6"
494
+ # GLM (Zhipu / Z.ai) — the five models registered in providers.rb.
495
+ # GLM-5V-Turbo is the vision variant; all five share the same Z.ai
496
+ # international flat-rate pricing regardless of which endpoint
497
+ # (mainland bigmodel.cn vs intl z.ai) the user configured.
498
+ # Strict anchored match so unrelated strings like "glm-5-x-foo"
499
+ # don't silently borrow a nearby model's rate.
500
+ when /^glm-5\.1$/i
501
+ "glm-5.1"
502
+ when /^glm-5v-turbo$/i
503
+ "glm-5v-turbo"
504
+ when /^glm-5-turbo$/i
505
+ "glm-5-turbo"
506
+ when /^glm-5$/i
507
+ "glm-5"
508
+ when /^glm-4\.7$/i
509
+ "glm-4.7"
510
+ # MiniMax — model ids in providers.rb use capitalised "MiniMax-M2.x"
511
+ # but we match case-insensitively and map to the lowercased table key.
512
+ when /^minimax-m2\.5$/i
513
+ "minimax-m2.5"
514
+ when /^minimax-m2\.7$/i
515
+ "minimax-m2.7"
421
516
 
422
517
  # OpenAI GPT-5.x models — match various dashed/dotted/compact forms
423
518
  # (e.g. "gpt-5.5", "gpt-5-5", "gpt5.5", "gpt55")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.2"
5
5
  end