openclacky 1.5.2 → 1.5.4

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +50 -0
  4. data/Dockerfile +21 -1
  5. data/README.md +26 -7
  6. data/README_CN.md +26 -7
  7. data/README_JA.md +26 -7
  8. data/lib/clacky/agent/message_compressor.rb +4 -29
  9. data/lib/clacky/agent/message_compressor_helper.rb +13 -15
  10. data/lib/clacky/agent/session_serializer.rb +6 -0
  11. data/lib/clacky/agent.rb +13 -1
  12. data/lib/clacky/agent_config.rb +42 -11
  13. data/lib/clacky/brand_config.rb +32 -12
  14. data/lib/clacky/client.rb +7 -0
  15. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
  16. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
  17. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
  19. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  20. data/lib/clacky/extension/api_extension.rb +3 -2
  21. data/lib/clacky/message_format/anthropic.rb +27 -1
  22. data/lib/clacky/message_format/open_ai.rb +92 -4
  23. data/lib/clacky/providers.rb +67 -14
  24. data/lib/clacky/server/http_server.rb +361 -36
  25. data/lib/clacky/server/project_manager.rb +150 -0
  26. data/lib/clacky/server/server_master.rb +7 -0
  27. data/lib/clacky/server/session_registry.rb +14 -1
  28. data/lib/clacky/session_manager.rb +5 -4
  29. data/lib/clacky/tools/file_reader.rb +2 -1
  30. data/lib/clacky/utils/model_pricing.rb +77 -18
  31. data/lib/clacky/utils/parser_manager.rb +115 -7
  32. data/lib/clacky/version.rb +1 -1
  33. data/lib/clacky/web/app.css +1053 -143
  34. data/lib/clacky/web/app.js +19 -5
  35. data/lib/clacky/web/components/code-editor.js +50 -12
  36. data/lib/clacky/web/components/notify.js +139 -22
  37. data/lib/clacky/web/core/aside.js +21 -0
  38. data/lib/clacky/web/features/billing/view.js +2 -1
  39. data/lib/clacky/web/features/brand/store.js +3 -1
  40. data/lib/clacky/web/features/brand/view.js +29 -5
  41. data/lib/clacky/web/features/extensions/store.js +15 -6
  42. data/lib/clacky/web/features/extensions/view.js +8 -4
  43. data/lib/clacky/web/features/new-session/view.js +2 -2
  44. data/lib/clacky/web/features/trash/store.js +23 -9
  45. data/lib/clacky/web/features/trash/view.js +51 -0
  46. data/lib/clacky/web/features/workspace/store.js +9 -0
  47. data/lib/clacky/web/features/workspace/view.js +8 -1
  48. data/lib/clacky/web/i18n.js +99 -4
  49. data/lib/clacky/web/index.html +70 -17
  50. data/lib/clacky/web/projects.js +1143 -0
  51. data/lib/clacky/web/sessions.js +318 -537
  52. data/lib/clacky/web/settings.js +146 -7
  53. data/lib/clacky/web/ws-dispatcher.js +59 -1
  54. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  55. data/scripts/install_system_deps.sh +11 -3
  56. metadata +5 -2
  57. data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
@@ -383,16 +383,16 @@ module Clacky
383
383
 
384
384
  # Returns true when a public distribution refresh is due.
385
385
  #
386
- # Refresh is needed only when the install has a package_name configured
387
- # but is not yet activated — activated licenses already get fresh
388
- # distribution data via #heartbeat! (once per 24h).
386
+ # Refresh is needed for unactivated branded installs and for private
387
+ # platform sources that have not supplied their deployment brand yet.
388
+ # Activated licenses already get fresh distribution data via #heartbeat!.
389
389
  #
390
390
  # Rate limit: once every HEARTBEAT_INTERVAL (24h), measured from the last
391
391
  # SUCCESSFUL refresh. A failed refresh does not advance the clock so we'll
392
392
  # keep trying on subsequent startups / status polls.
393
393
  def distribution_refresh_due?
394
- return false unless branded?
395
394
  return false if activated?
395
+ return false unless branded? || private_platform_source?
396
396
  return true if @distribution_last_refreshed_at.nil?
397
397
 
398
398
  elapsed = Time.now.utc - @distribution_last_refreshed_at
@@ -400,7 +400,8 @@ module Clacky
400
400
  end
401
401
 
402
402
  # Refresh public brand assets (logo, theme, homepage_url, support_*) for
403
- # installs that have `package_name` configured but no activated license yet.
403
+ # unactivated installs. Package installs use an exact package lookup;
404
+ # private platform sources without a local brand use the source default.
404
405
  #
405
406
  # Motivation: `install.sh --brand-name=X --command=X` only writes
406
407
  # product_name + package_name to brand.yml. The rest of the distribution
@@ -409,32 +410,47 @@ module Clacky
409
410
  # anonymous public lookup endpoint.
410
411
  #
411
412
  # Behaviour:
412
- # * No-op (returns { success: false, message: "..." }) when not branded,
413
- # already activated, or package_name is blank.
413
+ # * No-op when already activated, or when neither a package_name nor a
414
+ # private platform source is available.
414
415
  # * On success: apply_distribution + save + stamp
415
416
  # @distribution_last_refreshed_at.
417
+ # * When a block is provided, discard the response unless it still
418
+ # confirms that the platform source is current.
416
419
  # * On failure: log and return without touching the timestamp (so we
417
420
  # retry on next trigger).
418
421
  #
419
422
  # Returns { success: Boolean, message: String }.
420
423
  def refresh_distribution!
421
- unless branded?
424
+ source_brand_lookup = private_platform_source? &&
425
+ (@package_name.nil? || @package_name.strip.empty?)
426
+ unless branded? || source_brand_lookup
422
427
  return { success: false, message: "Not branded" }
423
428
  end
424
429
  if activated?
425
430
  return { success: false, message: "License activated — use heartbeat! instead" }
426
431
  end
427
- if @package_name.nil? || @package_name.strip.empty?
432
+ if !source_brand_lookup && (@package_name.nil? || @package_name.strip.empty?)
428
433
  return { success: false, message: "package_name not configured" }
429
434
  end
430
435
 
431
- encoded_pkg = URI.encode_www_form_component(@package_name.strip)
432
- path = "/api/v1/distributions/lookup?package_name=#{encoded_pkg}"
436
+ if source_brand_lookup
437
+ path = "/api/v1/distributions/lookup"
438
+ else
439
+ encoded_pkg = URI.encode_www_form_component(@package_name.strip)
440
+ path = "/api/v1/distributions/lookup?package_name=#{encoded_pkg}"
441
+ end
433
442
 
434
- Clacky::Logger.info("[Brand] refresh_distribution! fetching — package_name=#{@package_name}")
443
+ Clacky::Logger.info(
444
+ "[Brand] refresh_distribution! fetching — package_name=#{@package_name || "source-default"}"
445
+ )
435
446
  response = platform_client.get(path)
436
447
 
437
448
  if response[:success] && response[:data].is_a?(Hash) && response[:data]["distribution"].is_a?(Hash)
449
+ if block_given? && !yield
450
+ Clacky::Logger.info("[Brand] refresh_distribution! discarded — platform source changed")
451
+ return { success: false, message: "Platform source changed during refresh" }
452
+ end
453
+
438
454
  apply_distribution(response[:data]["distribution"])
439
455
  @distribution_last_refreshed_at = Time.now.utc
440
456
  save
@@ -1762,6 +1778,10 @@ module Clacky
1762
1778
  @homepage_url = dist["homepage_url"] if dist.key?("homepage_url")
1763
1779
  end
1764
1780
 
1781
+ private def private_platform_source?
1782
+ !ENV["CLACKY_LICENSE_SERVER"].to_s.strip.empty?
1783
+ end
1784
+
1765
1785
  # Download a remote URL to a local file path.
1766
1786
  #
1767
1787
  # Deprecated: this method now delegates to
data/lib/clacky/client.rb CHANGED
@@ -337,6 +337,13 @@ module Clacky
337
337
  # ── OpenAI request / response ─────────────────────────────────────────────
338
338
 
339
339
  def send_openai_request(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, on_chunk: nil, capability_model: nil)
340
+ # Override max_tokens when the model declares a higher output ceiling
341
+ # in Providers::MODEL_MAX_OUTPUT. Without this, strong models (GLM-5.2,
342
+ # Kimi-K3, MiMo-V2.5) are throttled to the 16K global default.
343
+ model_for_limit = capability_model || model
344
+ model_limit = Providers.max_output_for(model_for_limit)
345
+ max_tokens = model_limit if model_limit
346
+
340
347
  # Apply cache_control markers to messages when caching is enabled.
341
348
  # OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
342
349
  messages = apply_message_caching(messages) if caching_enabled
@@ -143,3 +143,18 @@ main product, and apply every rule below whenever you propose or write code:
143
143
  Ruby and carry supply-chain risk.
144
144
  - After editing `view.js`, `handler.rb`, or a `SKILL.md`, tell the user to reload the
145
145
  WebUI page — hot reload is per-request, no restart needed.
146
+ - After you finish editing extension files, call this once at the very end to trigger
147
+ a reload button in the UI (if it doesn't appear, the user can manually refresh the browser):
148
+ ```
149
+ curl -s --noproxy '*' -X POST "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/ui/show_ext_refresh" \
150
+ -H "Content-Type: application/json" \
151
+ -d "{\"session_id\": \"${CLACKY_SESSION_ID}\"}"
152
+ ```
153
+ (`--noproxy '*'` prevents shell proxy env vars from silently intercepting this loopback call.)
154
+ - If the extension contributes a `session.aside` panel, also call this right after the above
155
+ to open the panel automatically:
156
+ ```
157
+ curl -s --noproxy '*' -X POST "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/ui/open_aside" \
158
+ -H "Content-Type: application/json" \
159
+ -d "{\"session_id\": \"${CLACKY_SESSION_ID}\"}"
160
+ ```
@@ -201,11 +201,7 @@ class ExtStudioExt < Clacky::ApiExtension
201
201
  error!("extension not found: #{ext_id}", status: 404) unless container
202
202
 
203
203
  readme_path = File.join(container[:dir], "README.md")
204
- if readme.strip.empty?
205
- File.delete(readme_path) if File.exist?(readme_path)
206
- else
207
- File.write(readme_path, readme, encoding: "utf-8")
208
- end
204
+ File.write(readme_path, readme, encoding: "utf-8")
209
205
  end
210
206
 
211
207
  json(ok: true, ext_id: ext_id, saved_to: published ? "platform" : "local")
@@ -362,7 +358,7 @@ class ExtStudioExt < Clacky::ApiExtension
362
358
  post "/develop" do
363
359
  idea = presence(json_body["idea"])
364
360
  name = idea ? "扩展开发: #{idea[0, 40]}" : "扩展开发"
365
- sid = create_session(name: name, prompt: idea, profile: "ext-developer", source: :setup)
361
+ sid = create_session(name: name, prompt: idea, profile: "ext-developer")
366
362
  json(ok: true, session_id: sid)
367
363
  end
368
364
 
@@ -1539,7 +1539,7 @@
1539
1539
  let data = {};
1540
1540
  try { data = await res.json(); } catch (_e) {}
1541
1541
  if (!res.ok) throw new Error(data?.error || `Error ${res.status}`);
1542
- await reload(); rebuild();
1542
+ location.reload();
1543
1543
  } catch (e) {
1544
1544
  alert(t("err.generic", { msg: e.message }));
1545
1545
  btn.disabled = false;
@@ -1957,7 +1957,7 @@
1957
1957
  .studio-form-status { font-size: 12px; color: var(--color-text-tertiary); }
1958
1958
  .studio-btn-sm { padding: 4px 10px; font-size: 12px; }
1959
1959
  .studio-readme-overlay { align-items: center; justify-content: center; }
1960
- .studio-readme-modal { width: min(1200px, calc(100vw - 48px)); height: calc(100vh - 80px); max-width: calc(100vw - 48px); border-radius: var(--radius-md, 8px); margin: 0; padding: 24px 28px 20px; display: flex; flex-direction: column; }
1960
+ .studio-readme-modal { max-width: 1280px; width: 98%; height: 90vh; max-height: 960px; border-radius: var(--radius-md, 8px); margin: 0; padding: 24px 28px 20px; display: flex; flex-direction: column; }
1961
1961
  .studio-readme-modal .studio-modal-title { margin-bottom: 16px; flex-shrink: 0; }
1962
1962
  .studio-readme-split { display: flex; gap: 14px; flex: 1; min-height: 0; margin-bottom: 18px; }
1963
1963
  .studio-readme-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
@@ -1965,13 +1965,20 @@
1965
1965
  .studio-readme-textarea { flex: 1; resize: none; font-family: monospace; font-size: 12.5px; line-height: 1.65; border-radius: var(--radius-sm); }
1966
1966
  .studio-readme-preview { flex: 1; overflow-y: auto; overflow-x: hidden; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 12px 16px; background: var(--color-bg-secondary); font-size: 13px; line-height: 1.75; }
1967
1967
  .studio-readme-preview > *:first-child { margin-top: 0; }
1968
- .studio-readme-preview img { max-width: 100%; height: auto; display: block; border-radius: var(--radius-sm); }
1969
1968
  .studio-readme-screenshots { border-top: 1px solid var(--color-border-primary); padding-top: 14px; margin-bottom: 6px; }
1970
1969
  .studio-readme-screenshots-row { display: flex; align-items: center; gap: 10px; }
1971
1970
  .studio-readme-screenshots-hint { font-size: 11px; color: var(--color-text-muted); }
1972
1971
  .studio-readme-card { margin-top: 8px; }
1973
1972
  .studio-readme-card-preview { max-height: 200px; overflow-y: auto; font-size: 13px; }
1974
- .extension-readme-body img { max-width: min(800px, 100%); height: auto; }
1973
+ .extension-readme-body img { max-width: min(800px, 100%); height: auto; display: inline; vertical-align: middle; }
1974
+ .studio-readme-preview img { max-width: 100%; height: auto; display: inline; vertical-align: middle; }
1975
+ .studio-readme-preview hr, .extension-readme-body hr { border: none; border-top: 1px solid var(--color-border-primary); margin: 1.25em 0; }
1976
+ .studio-readme-preview table, .extension-readme-body table { border-collapse: collapse; width: 100%; margin: 0.75em 0; font-size: 0.8125rem; }
1977
+ .studio-readme-preview th, .studio-readme-preview td, .extension-readme-body th, .extension-readme-body td { border: 1px solid var(--color-border-primary); padding: 0.375em 0.75em; text-align: left; }
1978
+ .studio-readme-preview th, .extension-readme-body th { background: rgba(128,128,128,0.15); font-weight: 600; }
1979
+ .studio-readme-preview pre, .extension-readme-body pre { background: rgba(128,128,128,0.15); border-radius: 6px; padding: 0.75em 1em; overflow-x: auto; margin: 0.625em 0; }
1980
+ .studio-readme-preview code, .extension-readme-body code { font-family: monospace; font-size: 0.8125em; background: rgba(128,128,128,0.15); border-radius: 4px; padding: 0.15em 0.4em; }
1981
+ .studio-readme-preview pre code, .extension-readme-body pre code { background: none; padding: 0; }
1975
1982
  .studio-link-btn { background: none; border: none; padding: 0; font-size: 12px; color: var(--color-accent-primary); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
1976
1983
  .studio-link-btn:hover { color: var(--color-accent-hover); }
1977
1984
  `;
@@ -204,6 +204,10 @@ Response helpers: `json` / `text(str)` / `send_data(bytes, content_type:, filena
204
204
  - Drive sessions from the backend: `create_session(prompt:, profile:, …)`,
205
205
  `submit_task(session_id, prompt)`, `dispatch_to_session(session_id, prompt)` (runs a
206
206
  side task on a fork and returns its reply without touching the conversation).
207
+ - `create_session(hidden: true)` creates a session the openclacky session list hides
208
+ and the 200-session cleanup skips - use it for dedicated extension sessions you
209
+ manage yourself. Still reachable by `session_id` via `submit_task` /
210
+ `dispatch_to_session` / `registry.with_session`; forking resets `hidden` to false.
207
211
  - Public (no-auth) endpoints: call `public_endpoint("/path")` in the class **and** set
208
212
  `public: true` at ext.yml top level — both are required.
209
213
 
@@ -294,7 +298,7 @@ contributes:
294
298
  classes; swap the body for the real UI, POST to your own route:
295
299
 
296
300
  ```js
297
- Clacky.ext.ui.mount("session.aside", function (ctx) {
301
+ Clacky.ext.ui.mount("session.aside", function (container, ctx) {
298
302
  var el = document.createElement("div");
299
303
  el.style.padding = "16px";
300
304
  var input = document.createElement("input");
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """xlsx_parser.py — extract an XLSX/XLS workbook to Markdown tables.
3
+
4
+ Usage: python3 xlsx_parser.py <file_path>
5
+ Output: stdout — Markdown tables (UTF-8), one section per non-empty sheet
6
+ stderr — error messages
7
+ exit 0 success / 1 failure / 2 dependency missing
8
+
9
+ Uses openpyxl in read_only mode: a streaming reader that keeps memory
10
+ constant regardless of sheet size, so multi-megabyte worksheets parse in
11
+ seconds instead of exhausting RAM/CPU like a full DOM parse would.
12
+ """
13
+ # VERSION: 2
14
+ import sys
15
+
16
+
17
+ def build_table(rows):
18
+ width = max(len(r) for r in rows)
19
+ lines = []
20
+ for i, r in enumerate(rows):
21
+ padded = r + [""] * (width - len(r))
22
+ lines.append("| " + " | ".join(padded) + " |")
23
+ if i == 0:
24
+ lines.append("|" + " --- |" * width)
25
+ return "\n".join(lines)
26
+
27
+
28
+ def main():
29
+ if len(sys.argv) < 2:
30
+ sys.stderr.write("Usage: xlsx_parser.py <file_path>\n")
31
+ sys.exit(1)
32
+ path = sys.argv[1]
33
+
34
+ try:
35
+ from openpyxl import load_workbook
36
+ except ImportError as e:
37
+ sys.stderr.write(f"openpyxl not installed: {e}\n")
38
+ sys.stderr.write("Install with: pip3 install --user openpyxl\n")
39
+ sys.exit(2)
40
+
41
+ try:
42
+ wb = load_workbook(path, read_only=True, data_only=True)
43
+ except Exception as e:
44
+ sys.stderr.write(f"Failed to open workbook: {e}\n")
45
+ sys.exit(1)
46
+
47
+ sections = []
48
+ for ws in wb.worksheets:
49
+ rows = []
50
+ for row in ws.iter_rows(values_only=True):
51
+ cells = ["" if v is None else str(v) for v in row]
52
+ if any(c != "" for c in cells):
53
+ rows.append(cells)
54
+ if rows:
55
+ sections.append(f"### {ws.title}\n\n{build_table(rows)}")
56
+ wb.close()
57
+
58
+ if not sections:
59
+ sys.stderr.write("Spreadsheet appears to be empty\n")
60
+ sys.exit(1)
61
+
62
+ sys.stdout.write("\n\n".join(sections))
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
@@ -263,7 +263,7 @@ module Clacky
263
263
  # submitted immediately (the session starts running); display_message
264
264
  # controls the user-facing bubble shown in place of the raw prompt.
265
265
  def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
266
- source: :manual, display_message: nil)
266
+ source: :manual, display_message: nil, hidden: false)
267
267
  error!("server not ready", status: 503) unless @http_server
268
268
 
269
269
  session_id = @http_server.send(
@@ -271,7 +271,8 @@ module Clacky
271
271
  name: name,
272
272
  working_dir: working_dir,
273
273
  profile: profile,
274
- source: source
274
+ source: source,
275
+ hidden: hidden
275
276
  )
276
277
 
277
278
  submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
@@ -77,7 +77,33 @@ module Clacky
77
77
  body[:system] = system_text unless system_text.empty?
78
78
  body[:tools] = api_tools if api_tools&.any?
79
79
 
80
- if (effort = normalized_effort(reasoning_effort))
80
+ # Kimi (Moonshot Coding Plan) models routed through the Anthropic
81
+ # /v1/messages format expect thinking:{type:"enabled"} — the native
82
+ # Anthropic "adaptive" type is Claude-specific and is silently ignored
83
+ # by the Kimi backend, so thinking never actually activates on K3.
84
+ #
85
+ # K3 additionally supports a "max" effort level (strongest reasoning)
86
+ # beyond the standard low/medium/high triple. The generic
87
+ # normalized_effort() helper drops "max" because it only whitelists
88
+ # the standard three levels, making K3's signature strongest-reasoning
89
+ # mode unreachable through this format adapter.
90
+ #
91
+ # This branch only changes K3's wire format; every other model keeps
92
+ # using the adaptive path below unchanged. When reasoning_effort is
93
+ # nil/empty we leave the body untouched (provider default), and we
94
+ # never emit output_config for "on" so the backend can pick its own
95
+ # default — both behaviours match the official kimi CLI.
96
+ if model.to_s.match?(/\A(kimi-)?k3/i)
97
+ effort_str = reasoning_effort.to_s
98
+ k3_effort =
99
+ case effort_str
100
+ when "max", "xhigh" then "max"
101
+ when "high", "medium", "low" then effort_str
102
+ else nil
103
+ end
104
+ body[:thinking] = { type: "enabled" }
105
+ body[:output_config] = { effort: k3_effort } if k3_effort
106
+ elsif (effort = normalized_effort(reasoning_effort))
81
107
  body[:thinking] = { type: "adaptive" }
82
108
  body[:output_config] = { effort: effort }
83
109
  end
@@ -47,7 +47,7 @@ module Clacky
47
47
  def build_request_body(messages, model, tools, max_tokens, caching_enabled, vision_supported: true, reasoning_effort: nil)
48
48
  api_messages = messages.map { |msg| normalize_message_content(msg, vision_supported: vision_supported) }
49
49
 
50
- body = { model: model, max_tokens: max_tokens, messages: api_messages }
50
+ body = { model: model, token_field_for(model) => max_tokens, messages: api_messages }
51
51
 
52
52
  if tools&.any?
53
53
  if caching_enabled
@@ -59,9 +59,7 @@ module Clacky
59
59
  end
60
60
  end
61
61
 
62
- if reasoning_effort && !reasoning_effort.to_s.empty?
63
- body[:reasoning_effort] = reasoning_effort.to_s
64
- end
62
+ apply_reasoning_params(body, model, reasoning_effort)
65
63
 
66
64
  body
67
65
  end
@@ -196,6 +194,96 @@ module Clacky
196
194
 
197
195
  # ── Private helpers ───────────────────────────────────────────────────────
198
196
 
197
+ # Returns the token-limit field name for the given model.
198
+ # Most OpenAI-compatible APIs use :max_tokens; MiMo-V2.5 (Xiaomi) and
199
+ # MiniMax-M3 use :max_completion_tokens to cap the combined thinking +
200
+ # answer length (M3 documents max_completion_tokens over the legacy
201
+ # max_tokens field).
202
+ private_class_method def self.token_field_for(model)
203
+ if model.to_s.match?(/^mimo-v2/i) || model.to_s.match?(/^minimax-m3$/i)
204
+ :max_completion_tokens
205
+ else
206
+ :max_tokens
207
+ end
208
+ end
209
+
210
+ # Apply model-specific reasoning / thinking parameters to the request body.
211
+ #
212
+ # Different model families expose extended reasoning through different API
213
+ # fields. We map the shared internal reasoning_effort value to each
214
+ # family's native parameters in-place.
215
+ #
216
+ # Zero side-effects: when reasoning_effort is nil/empty the body is
217
+ # unchanged, preserving the provider default for all models.
218
+ private_class_method def self.apply_reasoning_params(body, model, reasoning_effort)
219
+ effort_str = reasoning_effort.to_s
220
+
221
+ if model.to_s.match?(/^glm-[45]/i)
222
+ # GLM (Zhipu / Z.ai) supports a native top-level "thinking" field
223
+ # ({type: "enabled"|"disabled"}) plus a restricted reasoning_effort
224
+ # that only accepts "max" or "high". Other effort levels collapse
225
+ # to "high". Send both fields so GLM activates thinking correctly.
226
+ if %w[off nothink disabled].include?(effort_str)
227
+ body[:thinking] = { type: "disabled" }
228
+ elsif !effort_str.empty?
229
+ glm_effort =
230
+ case effort_str
231
+ when "max", "xhigh" then "max"
232
+ when "high" then "high"
233
+ when "medium", "low" then "high" # GLM collapses these to "high"
234
+ else "max"
235
+ end
236
+ body[:thinking] = { type: "enabled" }
237
+ body[:reasoning_effort] = glm_effort
238
+ end
239
+ elsif model.to_s.match?(/^kimi-k3/i)
240
+ # Kimi K3 reasoning_effort only accepts low/high/max (default max).
241
+ # Thinking is always enabled and cannot be turned off; "off" maps
242
+ # to the lowest intensity "low". Unsupported effort levels (medium,
243
+ # xhigh) would be rejected or ignored by the server.
244
+ if %w[off nothink disabled].include?(effort_str)
245
+ body[:reasoning_effort] = "low"
246
+ elsif !effort_str.empty?
247
+ body[:reasoning_effort] =
248
+ case effort_str
249
+ when "max", "xhigh" then "max"
250
+ when "high" then "high"
251
+ when "medium", "low" then "low"
252
+ else "max"
253
+ end
254
+ end
255
+ elsif model.to_s.match?(/^mimo-v2/i)
256
+ # MiMo-V2.5 (Xiaomi) controls reasoning via a top-level
257
+ # thinking:{type:...} field rather than reasoning_effort. Map the
258
+ # internal reasoning_effort value to thinking on/off so MiMo does
259
+ # not receive an unsupported parameter.
260
+ if %w[off nothink disabled].include?(effort_str)
261
+ body[:thinking] = { type: "disabled" }
262
+ elsif !effort_str.empty?
263
+ body[:thinking] = { type: "enabled" }
264
+ end
265
+ elsif model.to_s.match?(/^minimax-m3$/i)
266
+ # MiniMax-M3 controls thinking via a top-level "thinking" field with
267
+ # { type: "adaptive" | "disabled" } — the same envelope shape MiMo
268
+ # uses, but with MiniMax-specific values. Adaptive thinking is the
269
+ # server default when the field is omitted, so we only send the
270
+ # field to explicitly switch modes:
271
+ # - reasoning_effort "off"/"disabled" → thinking disabled
272
+ # (M3 answers directly; M2.7 keeps thinking on regardless).
273
+ # - any other effort → adaptive thinking (M3
274
+ # decides per request); we let the server pick the intensity.
275
+ # M2.7 cannot disable thinking and ignores this field, so sending
276
+ # it is harmless for the rest of the MiniMax lineup.
277
+ if %w[off nothink disabled].include?(effort_str)
278
+ body[:thinking] = { type: "disabled" }
279
+ elsif !effort_str.empty?
280
+ body[:thinking] = { type: "adaptive" }
281
+ end
282
+ elsif reasoning_effort && !effort_str.empty?
283
+ body[:reasoning_effort] = effort_str
284
+ end
285
+ end
286
+
199
287
  private_class_method def self.parse_tool_calls(raw)
200
288
  return nil if raw.nil? || raw.empty?
201
289
 
@@ -29,12 +29,14 @@ module Clacky
29
29
  "name" => "OpenClacky",
30
30
  "base_url" => "https://api.openclacky.com",
31
31
  "api" => "bedrock",
32
- "default_model" => "abs-claude-sonnet-4-6",
32
+ "default_model" => "abs-claude-sonnet-5",
33
33
  "models" => [
34
34
  "abs-claude-fable-5",
35
+ "abs-claude-opus-5",
35
36
  "abs-claude-opus-4-8",
36
37
  "abs-claude-opus-4-7",
37
38
  "abs-claude-opus-4-6",
39
+ "abs-claude-sonnet-5",
38
40
  "abs-claude-sonnet-4-6",
39
41
  "abs-claude-sonnet-4-5",
40
42
  "abs-claude-haiku-4-5",
@@ -134,9 +136,11 @@ module Clacky
134
136
  # no injection happens when the default model is already lite-class.
135
137
  "lite_models" => {
136
138
  "abs-claude-fable-5" => "abs-claude-haiku-4-5",
139
+ "abs-claude-opus-5" => "abs-claude-haiku-4-5",
137
140
  "abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
138
141
  "abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
139
142
  "abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
143
+ "abs-claude-sonnet-5" => "abs-claude-haiku-4-5",
140
144
  "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
141
145
  "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
142
146
  "dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
@@ -145,7 +149,9 @@ module Clacky
145
149
  # Fallback chain: if a model is unavailable, try the next one in order.
146
150
  # Keys are primary model names; values are the fallback model to use instead.
147
151
  "fallback_models" => {
148
- "abs-claude-fable-5" => "abs-claude-opus-4-8",
152
+ "abs-claude-fable-5" => "abs-claude-opus-5",
153
+ "abs-claude-opus-5" => "abs-claude-opus-4-8",
154
+ "abs-claude-sonnet-5" => "abs-claude-sonnet-4-6",
149
155
  "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
150
156
  },
151
157
  # Secondary gateway URL used when the primary base_url is unreachable
@@ -254,18 +260,20 @@ module Clacky
254
260
  # so capability checks (vision=false) fire correctly regardless of
255
261
  # which endpoint the user configured.
256
262
  "endpoint_variants" => [
257
- { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
258
- { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
263
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
264
+ { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
259
265
  ].freeze,
260
- # MiniMax M2.x does not support multimodal/vision input on this endpoint.
261
- # M3 (released 2026-06-01) is natively multimodal and accepts image
262
- # input, so it overrides the provider-level vision=false below.
266
+ # MiniMax M2.5/M2.7 are text-only on this endpoint. M3 (released 2026-06-01)
267
+ # is natively multimodal — it accepts image input via OpenAI-style
268
+ # image_url content parts — so it overrides the provider-level
269
+ # vision=false below. M3 exposes a 1,000,000-token context window
270
+ # (M2.7: 204,800; M2.5: 40,960).
263
271
  "capabilities" => { "vision" => false }.freeze,
264
272
  "model_capabilities" => {
265
273
  "MiniMax-M3" => { "vision" => true }.freeze
266
274
  }.freeze,
267
275
  "default_ocr_model" => "MiniMax-M3",
268
- "website_url" => "https://www.minimaxi.com/user-center/basic-information/interface-key"
276
+ "website_url" => "https://platform.minimax.io/"
269
277
  }.freeze,
270
278
 
271
279
  "kimi" => {
@@ -348,13 +356,30 @@ module Clacky
348
356
  "base_url" => "https://api.xiaomimimo.com/v1",
349
357
  "api" => "openai-completions",
350
358
  "default_model" => "mimo-v2.5-pro",
351
- "models" => ["mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni"],
352
- # MiMo-V2-Pro is text-only; MiMo-V2-Omni supports vision (omni = multimodal).
359
+ # The MiMo-V2 family (mimo-v2-pro / mimo-v2-omni) was retired on
360
+ # 2026-06-30 and the model ids are no longer accepted by the API. The
361
+ # current lineup is the V2.5 series:
362
+ # - mimo-v2.5-pro: text reasoning flagship, no vision
363
+ # - mimo-v2.5: native omni-modal (image/video/audio/text), vision-capable
364
+ # Source: https://platform.xiaomimimo.com/docs/zh-CN/model
365
+ "models" => ["mimo-v2.5-pro", "mimo-v2.5"],
353
366
  "capabilities" => { "vision" => false }.freeze,
354
367
  "model_capabilities" => {
355
- "mimo-v2-omni" => { "vision" => true }.freeze
368
+ "mimo-v2.5" => { "vision" => true }.freeze
356
369
  }.freeze,
357
- "default_ocr_model" => "mimo-v2-omni",
370
+ "default_ocr_model" => "mimo-v2.5",
371
+ # Xiaomi serves the same V2.5 lineup from two billing endpoints: the
372
+ # pay-as-you-go API (api.xiaomimimo.com) and the Token Plan subscription
373
+ # endpoint (token-plan-cn.xiaomimimo.com). Both accept identical model
374
+ # ids and share one capability profile, so a single preset with
375
+ # endpoint_variants recognises both. Without this, users on the Token
376
+ # Plan endpoint fell through to "unknown provider" and the conservative
377
+ # vision=true default applied to every model — sending images to the
378
+ # text-only mimo-v2.5-pro, which rejects image input.
379
+ "endpoint_variants" => [
380
+ { "label" => "Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mimo_payg", "base_url" => "https://api.xiaomimimo.com/v1", "region" => "cn" }.freeze,
381
+ { "label" => "Token Plan", "label_key" => "settings.models.baseurl.variant.mimo_token_plan", "base_url" => "https://token-plan-cn.xiaomimimo.com/v1", "region" => "cn" }.freeze
382
+ ].freeze,
358
383
  "website_url" => "https://platform.xiaomimimo.com/"
359
384
  }.freeze,
360
385
 
@@ -362,8 +387,8 @@ module Clacky
362
387
  "name" => "GLM (Z.ai / Zhipu)",
363
388
  "base_url" => "https://open.bigmodel.cn/api/paas/v4",
364
389
  "api" => "openai-completions",
365
- "default_model" => "glm-5.1",
366
- "models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
390
+ "default_model" => "glm-5.2",
391
+ "models" => ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
367
392
  # Zhipu / Z.ai expose four functionally-equivalent endpoints:
368
393
  # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
369
394
  # each with a general-billing and a Coding-Plan subpath. They share the
@@ -520,6 +545,22 @@ module Clacky
520
545
 
521
546
  MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
522
547
 
548
+ # Per-model maximum output token limits.
549
+ #
550
+ # The Agent global default (@max_tokens = 16_384) is tuned for the lowest
551
+ # common denominator. Strong reasoning models (GLM-5.2, Kimi-K3, MiMo-V2.5)
552
+ # support 64K–128K output but are artificially throttled to 16K, causing
553
+ # truncation on long reasoning chains and code generation.
554
+ #
555
+ # Entries are matched top-to-bottom; the first match wins. Models not
556
+ # listed here fall back to the global default.
557
+ MODEL_MAX_OUTPUT = [
558
+ { pattern: /glm/i, limit: 65_536 }, # GLM-5.2: 128K output ceiling; 64K ample for reasoning+answer
559
+ { pattern: /kimi-k3/i, limit: 65_536 }, # Kimi K3: max_completion_tokens=131072 (max 1M); 64K ample
560
+ { pattern: /mimo-v2\.5-pro/i, limit: 65_536 }, # MiMo-V2.5-Pro: max_completion_tokens=131072; 64K ample
561
+ { pattern: /mimo/i, limit: 32_768 } # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
562
+ ].freeze
563
+
523
564
  class << self
524
565
  # Check if a provider preset exists
525
566
  # @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
@@ -559,6 +600,18 @@ module Clacky
559
600
  preset&.dig("api")
560
601
  end
561
602
 
603
+ # Resolve the per-model maximum output token limit.
604
+ # Returns nil when no MODEL_MAX_OUTPUT entry matches — callers should
605
+ # then fall back to the global default (@max_tokens).
606
+ #
607
+ # @param model_name [String] The model name (e.g. "glm-5.2")
608
+ # @return [Integer, nil] The max output limit, or nil if undeclared
609
+ def max_output_for(model_name)
610
+ return nil if model_name.nil? || model_name.to_s.empty?
611
+ entry = MODEL_MAX_OUTPUT.find { |e| model_name.to_s.match?(e[:pattern]) }
612
+ entry&.[](:limit)
613
+ end
614
+
562
615
  # Resolve the API type for a specific provider+model pair.
563
616
  #
564
617
  # Resolution order: