openclacky 1.5.1 → 1.5.3

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/agent/llm_caller.rb +48 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +7 -0
  5. data/lib/clacky/agent/session_serializer.rb +42 -0
  6. data/lib/clacky/agent/skill_manager.rb +7 -0
  7. data/lib/clacky/agent.rb +58 -1
  8. data/lib/clacky/agent_config.rb +71 -1
  9. data/lib/clacky/brand_config.rb +88 -59
  10. data/lib/clacky/client.rb +13 -2
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -1
  12. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +15 -3
  13. data/lib/clacky/extension/packager.rb +17 -0
  14. data/lib/clacky/locales/en.rb +4 -2
  15. data/lib/clacky/locales/zh.rb +5 -3
  16. data/lib/clacky/message_format/anthropic.rb +56 -1
  17. data/lib/clacky/message_format/open_ai.rb +69 -4
  18. data/lib/clacky/message_history.rb +15 -1
  19. data/lib/clacky/providers.rb +73 -6
  20. data/lib/clacky/server/http_server.rb +61 -29
  21. data/lib/clacky/server/session_registry.rb +4 -2
  22. data/lib/clacky/skill.rb +4 -0
  23. data/lib/clacky/tools/invoke_skill.rb +21 -0
  24. data/lib/clacky/tools/security.rb +11 -13
  25. data/lib/clacky/ui_interface.rb +2 -0
  26. data/lib/clacky/utils/model_pricing.rb +8 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +210 -21
  29. data/lib/clacky/web/app.js +8 -3
  30. data/lib/clacky/web/components/code-editor.js +50 -12
  31. data/lib/clacky/web/features/billing/view.js +7 -1
  32. data/lib/clacky/web/features/extensions/store.js +23 -7
  33. data/lib/clacky/web/features/extensions/view.js +8 -4
  34. data/lib/clacky/web/features/new-session/view.js +61 -0
  35. data/lib/clacky/web/features/workspace/store.js +9 -0
  36. data/lib/clacky/web/features/workspace/view.js +8 -1
  37. data/lib/clacky/web/i18n.js +14 -0
  38. data/lib/clacky/web/index.html +4 -2
  39. data/lib/clacky/web/settings.js +49 -14
  40. metadata +2 -2
@@ -51,6 +51,7 @@
51
51
  "err.version_conflict": "Version {{ver}} has already been published. Please enter a higher version number above and try again.",
52
52
  "err.invalid_version": "Invalid version. Use format like 1.0.0.",
53
53
  "err.name_taken": "The name \"{{id}}\" is already taken by another user.",
54
+ "err.id_folder_mismatch": "Extension ID mismatch: the folder is named \"{{folder}}\" but ext.yml declares id \"{{id}}\". Rename the folder or the id so they match before publishing.",
54
55
  "rename.label": "Choose a new ID for your extension:",
55
56
  "rename.placeholder": "e.g. my-ext-yourname",
56
57
  "rename.hint": "Only lowercase letters, digits and hyphens. This will rename the local folder and update ext.yml.",
@@ -235,6 +236,7 @@
235
236
  "err.version_conflict": "版本 {{ver}} 已发布过,请在上方输入更高的版本号后重试。",
236
237
  "err.invalid_version": "版本号格式不对,请使用如 1.0.0 的格式。",
237
238
  "err.name_taken": "扩展名「{{id}}」已被其他用户占用。",
239
+ "err.id_folder_mismatch": "扩展 ID 不一致:文件夹名为「{{folder}}」,但 ext.yml 声明的 id 是「{{id}}」。请先把文件夹名或 id 改成一致再发布。",
238
240
  "rename.label": "请为你的扩展选择一个新 ID:",
239
241
  "rename.placeholder": "例如 my-ext-yourname",
240
242
  "rename.hint": "只能使用小写字母、数字和连字符。",
@@ -726,6 +728,7 @@
726
728
  } catch (e) {
727
729
  const msg = e.message || "";
728
730
  const isConflict = /must be greater than/i.test(msg);
731
+ const mismatch = msg.match(/folder is '([^']+)' but ext\.yml declares id '([^']+)'/);
729
732
  if (isConflict) {
730
733
  setProgress(t("err.version_conflict", { ver }), true);
731
734
  verInput.classList.add("studio-input-error");
@@ -733,6 +736,8 @@
733
736
  verInput.focus();
734
737
  verInput.select();
735
738
  verInput.addEventListener("input", () => verInput.classList.remove("studio-input-error"), { once: true });
739
+ } else if (mismatch) {
740
+ setProgress(t("err.id_folder_mismatch", { folder: mismatch[1], id: mismatch[2] }), true);
736
741
  } else {
737
742
  setProgress(t("err.generic", { msg }), true);
738
743
  }
@@ -1952,7 +1957,7 @@
1952
1957
  .studio-form-status { font-size: 12px; color: var(--color-text-tertiary); }
1953
1958
  .studio-btn-sm { padding: 4px 10px; font-size: 12px; }
1954
1959
  .studio-readme-overlay { align-items: center; justify-content: center; }
1955
- .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; }
1956
1961
  .studio-readme-modal .studio-modal-title { margin-bottom: 16px; flex-shrink: 0; }
1957
1962
  .studio-readme-split { display: flex; gap: 14px; flex: 1; min-height: 0; margin-bottom: 18px; }
1958
1963
  .studio-readme-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
@@ -1960,13 +1965,20 @@
1960
1965
  .studio-readme-textarea { flex: 1; resize: none; font-family: monospace; font-size: 12.5px; line-height: 1.65; border-radius: var(--radius-sm); }
1961
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; }
1962
1967
  .studio-readme-preview > *:first-child { margin-top: 0; }
1963
- .studio-readme-preview img { max-width: 100%; height: auto; display: block; border-radius: var(--radius-sm); }
1964
1968
  .studio-readme-screenshots { border-top: 1px solid var(--color-border-primary); padding-top: 14px; margin-bottom: 6px; }
1965
1969
  .studio-readme-screenshots-row { display: flex; align-items: center; gap: 10px; }
1966
1970
  .studio-readme-screenshots-hint { font-size: 11px; color: var(--color-text-muted); }
1967
1971
  .studio-readme-card { margin-top: 8px; }
1968
1972
  .studio-readme-card-preview { max-height: 200px; overflow-y: auto; font-size: 13px; }
1969
- .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; }
1970
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; }
1971
1983
  .studio-link-btn:hover { color: var(--color-accent-hover); }
1972
1984
  `;
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "tmpdir"
5
5
  require "open-uri"
6
+ require "psych"
6
7
  require "zip"
7
8
 
8
9
  module Clacky
@@ -38,6 +39,7 @@ module Clacky
38
39
  raise Error, "no container found at #{container_dir} (expected #{MANIFEST})"
39
40
  end
40
41
 
42
+ refuse_id_folder_mismatch!(slug, container_dir)
41
43
  verify_container!(slug, container_dir)
42
44
  refuse_protected!(slug, container_dir)
43
45
 
@@ -96,6 +98,21 @@ module Clacky
96
98
  Clacky::ExtensionLoader.invalidate_cache!
97
99
  end
98
100
 
101
+ # The container's identity must be the same on both sides of the publish
102
+ # boundary: the client keys extensions by folder name, the marketplace
103
+ # keys them by ext.yml `id`. If they diverge, a rename on one side alone
104
+ # lets a container masquerade under a name the uniqueness check never saw.
105
+ private def refuse_id_folder_mismatch!(slug, container_dir)
106
+ manifest = File.join(container_dir, MANIFEST)
107
+ data = Psych.safe_load(File.read(manifest), permitted_classes: [], aliases: true) || {}
108
+ declared = data["id"].to_s.strip
109
+ return if declared == slug
110
+
111
+ raise Error,
112
+ "extension id mismatch: folder is '#{slug}' but #{MANIFEST} declares id '#{declared}'. " \
113
+ "Rename the folder or the id so they match before packing."
114
+ end
115
+
99
116
  # Packing is for authored (open) containers. Anything already protected or
100
117
  # carrying encrypted skills is produced by the platform pipeline; refuse
101
118
  # to re-pack it so we never smuggle marketplace artifacts out of band.
@@ -9,11 +9,12 @@ module Clacky
9
9
  "llm.error.403.model_not_allowed" => "This model is not available on your current plan",
10
10
  "llm.error.403.api_key_revoked" => "API key has been revoked, please generate a new one",
11
11
  "llm.error.403.api_key_expired" => "API key has expired, please generate a new one",
12
- "llm.error.403.quota_exceeded" => "Quota exceeded, please upgrade your plan",
12
+ "llm.error.403.quota_exceeded" => "API key quota exceeded. Please update the quota or disable the limit in the admin console",
13
13
  "llm.error.403.access_denied" => "Access denied, please check your API key permissions",
14
14
  "llm.error.403.default" => "Access denied",
15
15
  "llm.error.endpoint_not_found" => "API endpoint not found, please check your service URL",
16
16
  "llm.error.rate_limit_429" => "Rate limit exceeded, please wait a moment",
17
+ "llm.error.quota_exhausted" => "API quota exhausted (key usage limit reached). Please raise the quota or upgrade your plan in the console",
17
18
  "llm.error.server_error" => "Service temporarily unavailable (%<status>d), retrying...",
18
19
  "llm.error.unexpected" => "Unexpected error (%<status>d)",
19
20
  "llm.error.html_response" => "Service temporarily unavailable (received HTML error page), retrying...",
@@ -21,6 +22,7 @@ module Clacky
21
22
  "llm.error.request_timeout" => "Request timed out after %<retries>d retries",
22
23
  "llm.error.network_failed" => "Network connection failed after %<retries>d retries",
23
24
  "llm.error.service_unavailable" => "Service unavailable after %<retries>d retries",
25
+ "llm.warn.switching_to_fallback_url" => "Primary endpoint unreachable. Switching to fallback gateway: %<url>s",
24
26
  "platform.error.invalid_proof" => "Invalid license key — please check and try again.",
25
27
  "platform.error.invalid_signature" => "Invalid request signature.",
26
28
  "platform.error.nonce_replayed" => "Duplicate request detected. Please try again.",
@@ -28,7 +30,7 @@ module Clacky
28
30
  "platform.error.license_revoked" => "This license has been revoked. Please contact support.",
29
31
  "platform.error.license_expired" => "This license has expired. Please renew to continue.",
30
32
  "platform.error.device_limit_reached" => "Device limit reached for this license.",
31
- "platform.error.device_revoked" => "This device has been revoked from the license.",
33
+ "platform.error.device_revoked" => "This device has been revoked from the license. To re-activate, please wait 15 minutes and try again.",
32
34
  "platform.error.invalid_license" => "License key not found. Please verify the key.",
33
35
  "platform.error.device_not_found" => "Device not registered. Please re-activate.",
34
36
  "platform.error.contributor_required" => "Publishing extensions requires becoming a contributor. Sign in, open \"My Extensions\", and click \"Become a contributor\" (no review needed).",
@@ -9,11 +9,12 @@ module Clacky
9
9
  "llm.error.403.model_not_allowed" => "当前模型不支持免费试用,请升级套餐或切换其他模型",
10
10
  "llm.error.403.api_key_revoked" => "API 密钥已被撤销,请前往控制台重新生成",
11
11
  "llm.error.403.api_key_expired" => "API 密钥已过期,请前往控制台重新生成",
12
- "llm.error.403.quota_exceeded" => "配额已用完,请升级套餐",
12
+ "llm.error.403.quota_exceeded" => "API Key 配额已超限,请在管理后台调整配额或关闭限制",
13
13
  "llm.error.403.access_denied" => "访问被拒绝,请检查 API 密钥权限",
14
14
  "llm.error.403.default" => "访问被拒绝",
15
15
  "llm.error.endpoint_not_found" => "API 端点不存在,请检查服务地址配置",
16
- "llm.error.rate_limit_429" => "请求过于频繁,请稍候重试",
16
+ "llm.error.rate_limit_429" => "请求过于频繁,请稍后重试",
17
+ "llm.error.quota_exhausted" => "API 配额已用尽(已达 Key 的用量上限),请前往控制台提升配额或升级套餐后继续",
17
18
  "llm.error.server_error" => "服务暂时不可用(%<status>d),正在重试...",
18
19
  "llm.error.unexpected" => "请求失败(%<status>d)",
19
20
  "llm.error.html_response" => "服务暂时不可用(收到 HTML 错误页),正在重试...",
@@ -21,6 +22,7 @@ module Clacky
21
22
  "llm.error.request_timeout" => "请求超时(已重试 %<retries>d 次)",
22
23
  "llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次)",
23
24
  "llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次)",
25
+ "llm.warn.switching_to_fallback_url" => "主节点无法连接,正在切换到备用节点:%<url>s",
24
26
  "platform.error.invalid_proof" => "许可证密钥无效,请检查后重试。",
25
27
  "platform.error.invalid_signature" => "请求签名无效。",
26
28
  "platform.error.nonce_replayed" => "检测到重复请求,请重试。",
@@ -28,7 +30,7 @@ module Clacky
28
30
  "platform.error.license_revoked" => "该许可证已被吊销,请联系客服。",
29
31
  "platform.error.license_expired" => "该许可证已过期,请续订后继续。",
30
32
  "platform.error.device_limit_reached" => "该许可证的设备数量已达上限。",
31
- "platform.error.device_revoked" => "该设备已从许可证中移除。",
33
+ "platform.error.device_revoked" => "该设备已从许可证中移除。如需重新绑定,请等待 15 分钟后再试。",
32
34
  "platform.error.invalid_license" => "未找到许可证密钥,请核对后重试。",
33
35
  "platform.error.device_not_found" => "设备未注册,请重新激活。",
34
36
  "platform.error.contributor_required" => "发布扩展需要先成为扩展贡献者。请登录平台,打开「我的扩展」页面,点击「成为扩展贡献者」即可开通(无需审核)。",
@@ -66,6 +66,7 @@ module Clacky
66
66
  system_text = system_messages.map { |m| extract_text(m[:content]) }.join("\n\n")
67
67
 
68
68
  api_messages = regular_messages.map { |msg| to_api_message(msg, caching_enabled) }
69
+ api_messages = merge_consecutive_tool_results(api_messages)
69
70
  api_tools = tools&.map { |t| to_api_tool(t) }
70
71
 
71
72
  if caching_enabled && api_tools&.any?
@@ -76,7 +77,33 @@ module Clacky
76
77
  body[:system] = system_text unless system_text.empty?
77
78
  body[:tools] = api_tools if api_tools&.any?
78
79
 
79
- 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))
80
107
  body[:thinking] = { type: "adaptive" }
81
108
  body[:output_config] = { effort: effort }
82
109
  end
@@ -90,6 +117,34 @@ module Clacky
90
117
  %w[low medium high].include?(s) ? s : nil
91
118
  end
92
119
 
120
+ # Merge consecutive tool_result user messages into a single user message.
121
+ # Anthropic requires all tool_results for an assistant turn to appear in
122
+ # a SINGLE user message immediately following the assistant. When an
123
+ # assistant calls multiple tools, the canonical format produces one
124
+ # role:"tool" message per result, which to_api_message converts into
125
+ # separate role:"user" messages — breaking the Anthropic contract.
126
+ private_class_method def self.merge_consecutive_tool_results(api_messages)
127
+ result = []
128
+ pending = nil
129
+
130
+ api_messages.each do |msg|
131
+ if msg[:role] == "user" && msg[:content].is_a?(Array) &&
132
+ msg[:content].any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" }
133
+ if pending
134
+ pending[:content] += msg[:content]
135
+ else
136
+ pending = msg
137
+ result << pending
138
+ end
139
+ else
140
+ pending = nil
141
+ result << msg
142
+ end
143
+ end
144
+
145
+ result
146
+ end
147
+
93
148
  # ── Response parsing ──────────────────────────────────────────────────────
94
149
 
95
150
  # Parse Anthropic API response into canonical internal format.
@@ -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,73 @@ 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) uses
199
+ # :max_completion_tokens to cap the combined thinking + answer length.
200
+ private_class_method def self.token_field_for(model)
201
+ model.to_s.match?(/^mimo-v2/i) ? :max_completion_tokens : :max_tokens
202
+ end
203
+
204
+ # Apply model-specific reasoning / thinking parameters to the request body.
205
+ #
206
+ # Different model families expose extended reasoning through different API
207
+ # fields. We map the shared internal reasoning_effort value to each
208
+ # family's native parameters in-place.
209
+ #
210
+ # Zero side-effects: when reasoning_effort is nil/empty the body is
211
+ # unchanged, preserving the provider default for all models.
212
+ private_class_method def self.apply_reasoning_params(body, model, reasoning_effort)
213
+ effort_str = reasoning_effort.to_s
214
+
215
+ if model.to_s.match?(/^glm-[45]/i)
216
+ # GLM (Zhipu / Z.ai) supports a native top-level "thinking" field
217
+ # ({type: "enabled"|"disabled"}) plus a restricted reasoning_effort
218
+ # that only accepts "max" or "high". Other effort levels collapse
219
+ # to "high". Send both fields so GLM activates thinking correctly.
220
+ if %w[off nothink disabled].include?(effort_str)
221
+ body[:thinking] = { type: "disabled" }
222
+ elsif !effort_str.empty?
223
+ glm_effort =
224
+ case effort_str
225
+ when "max", "xhigh" then "max"
226
+ when "high" then "high"
227
+ when "medium", "low" then "high" # GLM collapses these to "high"
228
+ else "max"
229
+ end
230
+ body[:thinking] = { type: "enabled" }
231
+ body[:reasoning_effort] = glm_effort
232
+ end
233
+ elsif model.to_s.match?(/^kimi-k3/i)
234
+ # Kimi K3 reasoning_effort only accepts low/high/max (default max).
235
+ # Thinking is always enabled and cannot be turned off; "off" maps
236
+ # to the lowest intensity "low". Unsupported effort levels (medium,
237
+ # xhigh) would be rejected or ignored by the server.
238
+ if %w[off nothink disabled].include?(effort_str)
239
+ body[:reasoning_effort] = "low"
240
+ elsif !effort_str.empty?
241
+ body[:reasoning_effort] =
242
+ case effort_str
243
+ when "max", "xhigh" then "max"
244
+ when "high" then "high"
245
+ when "medium", "low" then "low"
246
+ else "max"
247
+ end
248
+ end
249
+ elsif model.to_s.match?(/^mimo-v2/i)
250
+ # MiMo-V2.5 (Xiaomi) controls reasoning via a top-level
251
+ # thinking:{type:...} field rather than reasoning_effort. Map the
252
+ # internal reasoning_effort value to thinking on/off so MiMo does
253
+ # not receive an unsupported parameter.
254
+ if %w[off nothink disabled].include?(effort_str)
255
+ body[:thinking] = { type: "disabled" }
256
+ elsif !effort_str.empty?
257
+ body[:thinking] = { type: "enabled" }
258
+ end
259
+ elsif reasoning_effort && !effort_str.empty?
260
+ body[:reasoning_effort] = effort_str
261
+ end
262
+ end
263
+
199
264
  private_class_method def self.parse_tool_calls(raw)
200
265
  return nil if raw.nil? || raw.empty?
201
266
 
@@ -10,7 +10,7 @@ module Clacky
10
10
  # Fields that are internal to the agent and must not be sent to the API.
11
11
  INTERNAL_FIELDS = %i[
12
12
  task_id created_at system_injected session_context memory_update
13
- subagent_instructions subagent_result token_usage
13
+ subagent_instructions subagent_result subagent_transcript token_usage
14
14
  compressed_summary chunk_path truncated transient
15
15
  chunk_index chunk_count
16
16
  ].freeze
@@ -71,6 +71,20 @@ module Clacky
71
71
  self
72
72
  end
73
73
 
74
+ # Attach a key/value onto the most recent tool result message matching the
75
+ # given tool_call_id. Handles both OpenAI-style (role:"tool", tool_call_id)
76
+ # and Anthropic-style (role:"user" with tool_result blocks) messages.
77
+ # No-op if no matching message is found.
78
+ def attach_to_tool_result(tool_call_id, key, value)
79
+ msg = @messages.reverse.find do |m|
80
+ (m[:role] == "tool" && m[:tool_call_id] == tool_call_id) ||
81
+ (m[:role] == "user" && m[:content].is_a?(Array) &&
82
+ m[:content].any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" && b[:tool_use_id] == tool_call_id })
83
+ end
84
+ msg[key] = value if msg
85
+ self
86
+ end
87
+
74
88
  # Mutate the last message matching the predicate lambda in-place.
75
89
  # Used by execute_skill_with_subagent to update instruction messages.
76
90
  def mutate_last_matching(predicate, &block)
@@ -148,6 +148,18 @@ module Clacky
148
148
  "abs-claude-fable-5" => "abs-claude-opus-4-8",
149
149
  "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
150
150
  },
151
+ # Secondary gateway URL used when the primary base_url is unreachable
152
+ # after max retries. The model name stays the same — only the endpoint
153
+ # changes. Nil / absent means no URL fallback for this provider.
154
+ "fallback_base_url" => "https://llm.1024code.com",
155
+ # Two selectable endpoints exposed in the Base URL dropdown:
156
+ # Primary — global CDN, lowest latency for most regions.
157
+ # Secondary — China-optimised relay (1024code.com), useful when
158
+ # the primary is unreachable from mainland China.
159
+ "endpoint_variants" => [
160
+ { "label" => "Primary (Global)", "label_key" => "settings.models.baseurl.variant.openclacky_primary", "base_url" => "https://api.openclacky.com" }.freeze,
161
+ { "label" => "Secondary (China)", "label_key" => "settings.models.baseurl.variant.openclacky_secondary", "base_url" => "https://llm.1024code.com" }.freeze
162
+ ].freeze,
151
163
  "website_url" => "https://www.openclacky.com/ai-keys"
152
164
  }.freeze,
153
165
 
@@ -336,13 +348,30 @@ module Clacky
336
348
  "base_url" => "https://api.xiaomimimo.com/v1",
337
349
  "api" => "openai-completions",
338
350
  "default_model" => "mimo-v2.5-pro",
339
- "models" => ["mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni"],
340
- # MiMo-V2-Pro is text-only; MiMo-V2-Omni supports vision (omni = multimodal).
351
+ # The MiMo-V2 family (mimo-v2-pro / mimo-v2-omni) was retired on
352
+ # 2026-06-30 and the model ids are no longer accepted by the API. The
353
+ # current lineup is the V2.5 series:
354
+ # - mimo-v2.5-pro: text reasoning flagship, no vision
355
+ # - mimo-v2.5: native omni-modal (image/video/audio/text), vision-capable
356
+ # Source: https://platform.xiaomimimo.com/docs/zh-CN/model
357
+ "models" => ["mimo-v2.5-pro", "mimo-v2.5"],
341
358
  "capabilities" => { "vision" => false }.freeze,
342
359
  "model_capabilities" => {
343
- "mimo-v2-omni" => { "vision" => true }.freeze
360
+ "mimo-v2.5" => { "vision" => true }.freeze
344
361
  }.freeze,
345
- "default_ocr_model" => "mimo-v2-omni",
362
+ "default_ocr_model" => "mimo-v2.5",
363
+ # Xiaomi serves the same V2.5 lineup from two billing endpoints: the
364
+ # pay-as-you-go API (api.xiaomimimo.com) and the Token Plan subscription
365
+ # endpoint (token-plan-cn.xiaomimimo.com). Both accept identical model
366
+ # ids and share one capability profile, so a single preset with
367
+ # endpoint_variants recognises both. Without this, users on the Token
368
+ # Plan endpoint fell through to "unknown provider" and the conservative
369
+ # vision=true default applied to every model — sending images to the
370
+ # text-only mimo-v2.5-pro, which rejects image input.
371
+ "endpoint_variants" => [
372
+ { "label" => "Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mimo_payg", "base_url" => "https://api.xiaomimimo.com/v1", "region" => "cn" }.freeze,
373
+ { "label" => "Token Plan", "label_key" => "settings.models.baseurl.variant.mimo_token_plan", "base_url" => "https://token-plan-cn.xiaomimimo.com/v1", "region" => "cn" }.freeze
374
+ ].freeze,
346
375
  "website_url" => "https://platform.xiaomimimo.com/"
347
376
  }.freeze,
348
377
 
@@ -350,8 +379,8 @@ module Clacky
350
379
  "name" => "GLM (Z.ai / Zhipu)",
351
380
  "base_url" => "https://open.bigmodel.cn/api/paas/v4",
352
381
  "api" => "openai-completions",
353
- "default_model" => "glm-5.1",
354
- "models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
382
+ "default_model" => "glm-5.2",
383
+ "models" => ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
355
384
  # Zhipu / Z.ai expose four functionally-equivalent endpoints:
356
385
  # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
357
386
  # each with a general-billing and a Coding-Plan subpath. They share the
@@ -508,6 +537,22 @@ module Clacky
508
537
 
509
538
  MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
510
539
 
540
+ # Per-model maximum output token limits.
541
+ #
542
+ # The Agent global default (@max_tokens = 16_384) is tuned for the lowest
543
+ # common denominator. Strong reasoning models (GLM-5.2, Kimi-K3, MiMo-V2.5)
544
+ # support 64K–128K output but are artificially throttled to 16K, causing
545
+ # truncation on long reasoning chains and code generation.
546
+ #
547
+ # Entries are matched top-to-bottom; the first match wins. Models not
548
+ # listed here fall back to the global default.
549
+ MODEL_MAX_OUTPUT = [
550
+ { pattern: /glm/i, limit: 65_536 }, # GLM-5.2: 128K output ceiling; 64K ample for reasoning+answer
551
+ { pattern: /kimi-k3/i, limit: 65_536 }, # Kimi K3: max_completion_tokens=131072 (max 1M); 64K ample
552
+ { pattern: /mimo-v2\.5-pro/i, limit: 65_536 }, # MiMo-V2.5-Pro: max_completion_tokens=131072; 64K ample
553
+ { pattern: /mimo/i, limit: 32_768 } # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
554
+ ].freeze
555
+
511
556
  class << self
512
557
  # Check if a provider preset exists
513
558
  # @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
@@ -547,6 +592,18 @@ module Clacky
547
592
  preset&.dig("api")
548
593
  end
549
594
 
595
+ # Resolve the per-model maximum output token limit.
596
+ # Returns nil when no MODEL_MAX_OUTPUT entry matches — callers should
597
+ # then fall back to the global default (@max_tokens).
598
+ #
599
+ # @param model_name [String] The model name (e.g. "glm-5.2")
600
+ # @return [Integer, nil] The max output limit, or nil if undeclared
601
+ def max_output_for(model_name)
602
+ return nil if model_name.nil? || model_name.to_s.empty?
603
+ entry = MODEL_MAX_OUTPUT.find { |e| model_name.to_s.match?(e[:pattern]) }
604
+ entry&.[](:limit)
605
+ end
606
+
550
607
  # Resolve the API type for a specific provider+model pair.
551
608
  #
552
609
  # Resolution order:
@@ -779,6 +836,16 @@ module Clacky
779
836
  preset&.dig("fallback_models", model)
780
837
  end
781
838
 
839
+ # Get the fallback base URL for a provider (used when primary endpoint is unreachable).
840
+ # Returns nil if the provider has no secondary gateway configured.
841
+ # @param provider_id [String] The provider identifier
842
+ # @return [String, nil] The fallback base URL or nil
843
+ def fallback_base_url(provider_id)
844
+ preset = PRESETS[provider_id]
845
+ url = preset&.dig("fallback_base_url")
846
+ url&.empty? ? nil : url
847
+ end
848
+
782
849
  # Find provider ID by base URL.
783
850
  # Matches if the given URL starts with the provider's base_url (after normalisation),
784
851
  # so both exact matches and sub-path variants (e.g. "/v1") are recognised.