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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/lib/clacky/agent/llm_caller.rb +48 -0
- data/lib/clacky/agent/message_compressor_helper.rb +7 -0
- data/lib/clacky/agent/session_serializer.rb +42 -0
- data/lib/clacky/agent/skill_manager.rb +7 -0
- data/lib/clacky/agent.rb +58 -1
- data/lib/clacky/agent_config.rb +71 -1
- data/lib/clacky/brand_config.rb +88 -59
- data/lib/clacky/client.rb +13 -2
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +15 -3
- data/lib/clacky/extension/packager.rb +17 -0
- data/lib/clacky/locales/en.rb +4 -2
- data/lib/clacky/locales/zh.rb +5 -3
- data/lib/clacky/message_format/anthropic.rb +56 -1
- data/lib/clacky/message_format/open_ai.rb +69 -4
- data/lib/clacky/message_history.rb +15 -1
- data/lib/clacky/providers.rb +73 -6
- data/lib/clacky/server/http_server.rb +61 -29
- data/lib/clacky/server/session_registry.rb +4 -2
- data/lib/clacky/skill.rb +4 -0
- data/lib/clacky/tools/invoke_skill.rb +21 -0
- data/lib/clacky/tools/security.rb +11 -13
- data/lib/clacky/ui_interface.rb +2 -0
- data/lib/clacky/utils/model_pricing.rb +8 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +210 -21
- data/lib/clacky/web/app.js +8 -3
- data/lib/clacky/web/components/code-editor.js +50 -12
- data/lib/clacky/web/features/billing/view.js +7 -1
- data/lib/clacky/web/features/extensions/store.js +23 -7
- data/lib/clacky/web/features/extensions/view.js +8 -4
- data/lib/clacky/web/features/new-session/view.js +61 -0
- data/lib/clacky/web/features/workspace/store.js +9 -0
- data/lib/clacky/web/features/workspace/view.js +8 -1
- data/lib/clacky/web/i18n.js +14 -0
- data/lib/clacky/web/index.html +4 -2
- data/lib/clacky/web/settings.js +49 -14
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04d2d81c4aaa380d4600066802c78e1137bdad1cb16c3e5e47e32e69ede15da6
|
|
4
|
+
data.tar.gz: 9a332e0305e96bae97799ce707ef492b24735e33ef8da70a390c06a10ac11ab4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 435b6c1bd6aac4c2916cf172e5d46dece9a5707d1b18119d4744d29293c1f7b8fb112709718d24b8b9b3a871bfa228294a282525d9b6f7ed14550b89eaaa2793
|
|
7
|
+
data.tar.gz: 90599042f3d933db9b7681c33d539937585cd85542d635696ea159056aa47dbe34f86971bd1db6541c4dee28ee73c66e8f4648d91d8f3b1897b179ce9a2b00fc
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
|
|
7
|
+
## [1.5.3] - 2026-07-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Per-model max output token limits (MODEL_MAX_OUTPUT) applied automatically when sending requests
|
|
11
|
+
- GLM-5.2 added to preset model lineup with pricing table
|
|
12
|
+
- MiMo-V2.5 thinking mode support with max_completion_tokens
|
|
13
|
+
- reasoning_effort mapped to GLM thinking mode and Kimi-K3 thinking levels
|
|
14
|
+
- File-tree save support added to CodeEditor with markdown split-preview panel
|
|
15
|
+
- Unlisted badge and source=brand routing for brand-private extensions
|
|
16
|
+
|
|
17
|
+
### Improved
|
|
18
|
+
- Markdown preview styles refined: hr, inline images, tables, modal sizing
|
|
19
|
+
- OpenAI client refactored with token_field_for and apply_reasoning_params helpers
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- URL fallback state (base URL) now resets correctly when switching models in quick switch
|
|
23
|
+
- Kimi K3 now uses correct thinking:{type:'enabled'} format for Anthropic client
|
|
24
|
+
- MiMo preset aligned with V2.5 lineup and Token Plan endpoint
|
|
25
|
+
- ext-studio sessions no longer incorrectly tagged as setup sessions (C-5722)
|
|
26
|
+
- localhost URLs no longer trigger OpenClacky failover (find_by_base_url guard)
|
|
27
|
+
- API key toggle icon default and click states corrected
|
|
28
|
+
- Share button on billing page now uses SVG icon instead of emoji
|
|
29
|
+
- Share button hover border color uses accent-primary token
|
|
30
|
+
|
|
31
|
+
## [1.5.2] - 2026-07-27
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- Starter prompts for ext-developer agent on new session page
|
|
35
|
+
- Secondary gateway endpoint for OpenClacky provider with auto-fallback after all retries exhausted
|
|
36
|
+
- Subagent save support and invoke_skill format improvements
|
|
37
|
+
|
|
38
|
+
### Improved
|
|
39
|
+
- Curly quotes wrapping for starter prompt text
|
|
40
|
+
- Brand license badge now distinguishes unreachable vs expiring states
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- HTTP 429 quota_exceeded now shows correct "quota exceeded" error instead of "rate limit" and stops retrying
|
|
44
|
+
- Search query filter now applies correctly to brand extensions in marketplace
|
|
45
|
+
- Billing page colors unified to warm palette
|
|
46
|
+
- Sub-model switching and card display now use saved provider_id correctly
|
|
47
|
+
- Device revoked error message now includes 15-minute wait hint
|
|
48
|
+
- Extension detail page routes by source param for brand/public distinction
|
|
49
|
+
- Non-slug skill names preserved as name_zh fallback for Chinese matching
|
|
50
|
+
- Sudo and .env writes unblocked in subagent; double-compression skipped
|
|
51
|
+
- Claude tool call ID handling fixed
|
|
52
|
+
- Brand skill preserved when auto-update download fails (C-5754)
|
|
53
|
+
- Extension packing rejected when folder name diverges from ext.yml id (C-5752)
|
|
54
|
+
|
|
7
55
|
## [1.5.1] - 2026-07-24
|
|
8
56
|
|
|
9
57
|
### Fixed
|
|
@@ -201,6 +201,20 @@ module Clacky
|
|
|
201
201
|
sleep retry_delay
|
|
202
202
|
retry
|
|
203
203
|
else
|
|
204
|
+
# All retries exhausted for a timeout. Try the secondary gateway URL if available.
|
|
205
|
+
if !@config.url_fallback_active?
|
|
206
|
+
fallback_url = @config.fallback_base_url_for_current_provider
|
|
207
|
+
if fallback_url
|
|
208
|
+
@config.activate_url_fallback!(fallback_url)
|
|
209
|
+
rebuild_client_for_current_model!
|
|
210
|
+
retries = 0
|
|
211
|
+
@ui&.show_warning(
|
|
212
|
+
I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
|
|
213
|
+
)
|
|
214
|
+
retry
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
204
218
|
raise AgentError.new("[LLM] #{I18n.t("llm.error.request_timeout", retries: max_retries)}", raw_message: e.message)
|
|
205
219
|
end
|
|
206
220
|
|
|
@@ -234,6 +248,21 @@ module Clacky
|
|
|
234
248
|
sleep retry_delay
|
|
235
249
|
retry
|
|
236
250
|
else
|
|
251
|
+
# All retries on the primary endpoint exhausted for a network-level
|
|
252
|
+
# failure (DNS/TCP/TLS). Try the secondary gateway URL if available.
|
|
253
|
+
if !@config.url_fallback_active?
|
|
254
|
+
fallback_url = @config.fallback_base_url_for_current_provider
|
|
255
|
+
if fallback_url
|
|
256
|
+
@config.activate_url_fallback!(fallback_url)
|
|
257
|
+
rebuild_client_for_current_model!
|
|
258
|
+
retries = 0
|
|
259
|
+
@ui&.show_warning(
|
|
260
|
+
I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
|
|
261
|
+
)
|
|
262
|
+
retry
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
237
266
|
# Don't show_error here — let the outer rescue block handle it to avoid duplicates.
|
|
238
267
|
# Progress cleanup is the caller's responsibility (via its own ensure block).
|
|
239
268
|
raise AgentError.new("[LLM] #{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
|
|
@@ -272,6 +301,25 @@ module Clacky
|
|
|
272
301
|
sleep retry_delay
|
|
273
302
|
retry
|
|
274
303
|
else
|
|
304
|
+
# All retries on the current endpoint exhausted.
|
|
305
|
+
# If a secondary gateway URL is configured and not yet tried, switch
|
|
306
|
+
# to it now, rebuild the client, and reset the retry counter so the
|
|
307
|
+
# same request gets a fresh MAX_RETRIES_ON_FALLBACK budget on the
|
|
308
|
+
# new endpoint. This is a one-shot switch — url_fallback_active?
|
|
309
|
+
# prevents a second switch if the fallback URL also fails.
|
|
310
|
+
if !@config.url_fallback_active?
|
|
311
|
+
fallback_url = @config.fallback_base_url_for_current_provider
|
|
312
|
+
if fallback_url
|
|
313
|
+
@config.activate_url_fallback!(fallback_url)
|
|
314
|
+
rebuild_client_for_current_model!
|
|
315
|
+
retries = 0
|
|
316
|
+
@ui&.show_warning(
|
|
317
|
+
I18n.t("llm.warn.switching_to_fallback_url", url: fallback_url)
|
|
318
|
+
)
|
|
319
|
+
retry
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
275
323
|
# Don't show_error here — let the outer rescue block handle it to avoid duplicates.
|
|
276
324
|
# Progress cleanup is the caller's responsibility (via its own ensure block).
|
|
277
325
|
raise AgentError.new("[LLM] #{I18n.t("llm.error.service_unavailable", retries: current_max)}", raw_message: e.message)
|
|
@@ -122,6 +122,13 @@ module Clacky
|
|
|
122
122
|
# handle_compression_response, so recent task progress is preserved.
|
|
123
123
|
# @return [Hash, nil] Compression context or nil if not needed
|
|
124
124
|
def compress_messages_if_needed(force: false, pull_back_from_tail: 0)
|
|
125
|
+
# Subagents must not proactively compress — their history is a clone of the
|
|
126
|
+
# parent's and will be discarded when the subagent exits. The parent will
|
|
127
|
+
# compress its own history after the subagent returns, so doing it here is
|
|
128
|
+
# pure waste (same data compressed twice). Context-overflow recovery
|
|
129
|
+
# (force: true) is still allowed as a safety net for long-running subagents.
|
|
130
|
+
return nil if @is_subagent && !force
|
|
131
|
+
|
|
125
132
|
# Check if compression is enabled
|
|
126
133
|
return nil unless @config.enable_compression
|
|
127
134
|
|
|
@@ -641,13 +641,55 @@ module Clacky
|
|
|
641
641
|
|
|
642
642
|
ui.show_tool_result(blk[:content].to_s)
|
|
643
643
|
end
|
|
644
|
+
replay_subagent_transcript(msg, ui)
|
|
644
645
|
|
|
645
646
|
when "tool"
|
|
646
647
|
# OpenAI-format tool result
|
|
647
648
|
ui.show_tool_result(msg[:content].to_s)
|
|
649
|
+
replay_subagent_transcript(msg, ui)
|
|
648
650
|
end
|
|
649
651
|
end
|
|
650
652
|
|
|
653
|
+
# Replay a subagent transcript stored on a tool result message. Emits a
|
|
654
|
+
# bracketed sequence of UI events the frontend can render as a collapsible
|
|
655
|
+
# sub-process block: subagent_start → (assistant_message / tool_call /
|
|
656
|
+
# tool_result)* → subagent_end. No-op when the message carries no transcript.
|
|
657
|
+
def replay_subagent_transcript(msg, ui)
|
|
658
|
+
transcript = msg[:subagent_transcript]
|
|
659
|
+
return unless transcript.is_a?(Hash)
|
|
660
|
+
|
|
661
|
+
events = transcript[:events] || transcript["events"] || []
|
|
662
|
+
return if events.empty?
|
|
663
|
+
|
|
664
|
+
ui.show_subagent_start(
|
|
665
|
+
skill: transcript[:skill] || transcript["skill"],
|
|
666
|
+
iterations: transcript[:iterations] || transcript["iterations"],
|
|
667
|
+
cost_usd: transcript[:cost_usd] || transcript["cost_usd"]
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
events.each do |ev|
|
|
671
|
+
role = (ev[:role] || ev["role"]).to_s
|
|
672
|
+
content = ev[:content] || ev["content"]
|
|
673
|
+
tool_calls = ev[:tool_calls] || ev["tool_calls"]
|
|
674
|
+
|
|
675
|
+
case role
|
|
676
|
+
when "assistant"
|
|
677
|
+
text = extract_text_from_content(content).to_s.strip
|
|
678
|
+
ui.show_assistant_message(text, files: []) unless text.empty?
|
|
679
|
+
Array(tool_calls).each do |tc|
|
|
680
|
+
name = tc[:name] || tc["name"] || ""
|
|
681
|
+
args_raw = tc[:arguments] || tc["arguments"] || {}
|
|
682
|
+
args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue args_raw) : args_raw
|
|
683
|
+
ui.show_tool_call(name, args)
|
|
684
|
+
end
|
|
685
|
+
when "tool", "user"
|
|
686
|
+
ui.show_tool_result(extract_text_from_content(content).to_s)
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
ui.show_subagent_end
|
|
691
|
+
end
|
|
692
|
+
|
|
651
693
|
# Replace the system message in @messages with a freshly built system prompt.
|
|
652
694
|
# Called after restore_session so newly installed skills and any other
|
|
653
695
|
# configuration changes since the session was saved take effect immediately.
|
|
@@ -639,6 +639,13 @@ module Clacky
|
|
|
639
639
|
# Generate summary
|
|
640
640
|
summary = generate_subagent_summary(subagent)
|
|
641
641
|
|
|
642
|
+
# Capture the subagent's own message trail (everything it appended after
|
|
643
|
+
# the fork) so the parent session can persist and later replay the full
|
|
644
|
+
# subagent process — not just the collapsed summary. Stored transiently
|
|
645
|
+
# on the parent agent; observe() attaches it to the invoke_skill tool
|
|
646
|
+
# result message. Kept out of the LLM payload via INTERNAL_FIELDS.
|
|
647
|
+
@pending_subagent_transcript = extract_subagent_transcript(subagent, skill.identifier)
|
|
648
|
+
|
|
642
649
|
# Mutate the subagent_instructions message in-place to become the result summary
|
|
643
650
|
@history.mutate_last_matching(->(m) { m[:subagent_instructions] }) do |m|
|
|
644
651
|
m[:content] = summary
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -105,6 +105,7 @@ module Clacky
|
|
|
105
105
|
@ui = ui # UIController for direct UI interaction
|
|
106
106
|
@debug_logs = [] # Debug logs for troubleshooting
|
|
107
107
|
@pending_injections = [] # Pending inline skill injections to flush after observe()
|
|
108
|
+
@pending_subagent_transcript = nil # Subagent trail to attach to the next tool result (observe)
|
|
108
109
|
@pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
|
|
109
110
|
@pending_error_rollback = false # Deferred rollback flag set by restore_session on error
|
|
110
111
|
@last_run_interrupted = false # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)
|
|
@@ -212,7 +213,7 @@ module Clacky
|
|
|
212
213
|
private def rebuild_client_for_current_model!
|
|
213
214
|
@client = Clacky::Client.new(
|
|
214
215
|
@config.api_key,
|
|
215
|
-
base_url: @config.
|
|
216
|
+
base_url: @config.effective_base_url,
|
|
216
217
|
model: @config.model_name,
|
|
217
218
|
anthropic_format: @config.anthropic_format?
|
|
218
219
|
)
|
|
@@ -249,6 +250,7 @@ module Clacky
|
|
|
249
250
|
id: model["id"],
|
|
250
251
|
model: model["model"],
|
|
251
252
|
base_url: model["base_url"],
|
|
253
|
+
provider_id: model["provider_id"],
|
|
252
254
|
card_model: base_entry&.dig("model"),
|
|
253
255
|
sub_model: sub_model
|
|
254
256
|
}
|
|
@@ -1138,6 +1140,8 @@ module Clacky
|
|
|
1138
1140
|
@history.append(truncated.merge(task_id: @current_task_id))
|
|
1139
1141
|
end
|
|
1140
1142
|
|
|
1143
|
+
attach_pending_subagent_transcript(response)
|
|
1144
|
+
|
|
1141
1145
|
# Append a follow-up `role:"user"` message for any image payloads that
|
|
1142
1146
|
# could not be delivered inside the tool message.
|
|
1143
1147
|
#
|
|
@@ -1181,6 +1185,24 @@ module Clacky
|
|
|
1181
1185
|
end
|
|
1182
1186
|
end
|
|
1183
1187
|
|
|
1188
|
+
# Attach the captured subagent transcript (set by execute_skill_with_subagent)
|
|
1189
|
+
# to the invoke_skill tool result message just appended by observe(). The
|
|
1190
|
+
# transcript rides on the tool message as :subagent_transcript — an internal
|
|
1191
|
+
# field stripped before the LLM call but persisted to session.json and
|
|
1192
|
+
# replayed to the WebUI. Cleared after attaching so it fires exactly once.
|
|
1193
|
+
private def attach_pending_subagent_transcript(response)
|
|
1194
|
+
transcript = @pending_subagent_transcript
|
|
1195
|
+
return unless transcript
|
|
1196
|
+
|
|
1197
|
+
@pending_subagent_transcript = nil
|
|
1198
|
+
|
|
1199
|
+
skill_call = Array(response[:tool_calls]).find { |tc| (tc[:name] || tc.dig(:function, :name)) == "invoke_skill" }
|
|
1200
|
+
target_id = skill_call && skill_call[:id]
|
|
1201
|
+
return unless target_id
|
|
1202
|
+
|
|
1203
|
+
@history.attach_to_tool_result(target_id, :subagent_transcript, transcript)
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1184
1206
|
# Cap oversized tool result content to keep a single tool message from
|
|
1185
1207
|
# blowing up the prompt budget (issue #218: a 7350-path glob produced a
|
|
1186
1208
|
# ~890k-char result that pushed history past the model context window
|
|
@@ -1541,6 +1563,41 @@ module Clacky
|
|
|
1541
1563
|
parts.join("\n")
|
|
1542
1564
|
end
|
|
1543
1565
|
|
|
1566
|
+
# Extract the subagent's own message trail for persistence/replay.
|
|
1567
|
+
# Returns a trimmed, LLM-free array of {role, content, tool_calls} hashes
|
|
1568
|
+
# capturing only what the subagent did after the fork — system-injected
|
|
1569
|
+
# scaffolding (fork instructions, ack) is dropped. Stored on the parent's
|
|
1570
|
+
# invoke_skill tool result under :subagent_transcript so the WebUI can
|
|
1571
|
+
# render a collapsible sub-process without polluting the main thread.
|
|
1572
|
+
def extract_subagent_transcript(subagent, skill_identifier)
|
|
1573
|
+
parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
|
|
1574
|
+
new_messages = subagent.history.to_a[parent_count..] || []
|
|
1575
|
+
|
|
1576
|
+
events = new_messages.filter_map do |m|
|
|
1577
|
+
next if m[:system_injected]
|
|
1578
|
+
role = m[:role].to_s
|
|
1579
|
+
next unless %w[assistant tool user].include?(role)
|
|
1580
|
+
|
|
1581
|
+
entry = { role: role }
|
|
1582
|
+
entry[:content] = m[:content] if m[:content] && !m[:content].to_s.empty?
|
|
1583
|
+
if m[:tool_calls].is_a?(Array) && !m[:tool_calls].empty?
|
|
1584
|
+
entry[:tool_calls] = m[:tool_calls].map do |tc|
|
|
1585
|
+
func = tc[:function] || tc
|
|
1586
|
+
{ name: func[:name] || tc[:name], arguments: func[:arguments] || tc[:arguments] || {} }
|
|
1587
|
+
end
|
|
1588
|
+
end
|
|
1589
|
+
entry[:tool_call_id] = m[:tool_call_id] if m[:tool_call_id]
|
|
1590
|
+
entry.key?(:content) || entry.key?(:tool_calls) ? entry : nil
|
|
1591
|
+
end
|
|
1592
|
+
|
|
1593
|
+
{
|
|
1594
|
+
skill: skill_identifier,
|
|
1595
|
+
iterations: subagent.iterations,
|
|
1596
|
+
cost_usd: subagent.total_cost.round(4),
|
|
1597
|
+
events: events
|
|
1598
|
+
}
|
|
1599
|
+
end
|
|
1600
|
+
|
|
1544
1601
|
# Deep clone helper for messages using Marshal
|
|
1545
1602
|
# @param obj [Object] Object to clone
|
|
1546
1603
|
# @return [Object] Deep cloned object
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -476,7 +476,13 @@ module Clacky
|
|
|
476
476
|
@current_model_id = id
|
|
477
477
|
@current_model_index = index
|
|
478
478
|
|
|
479
|
-
|
|
479
|
+
if previous_id != id
|
|
480
|
+
@session_model_overlay = nil
|
|
481
|
+
# Reset URL fallback when switching models — the new model has its own
|
|
482
|
+
# base_url and the old fallback endpoint is irrelevant (or even wrong).
|
|
483
|
+
@url_fallback_active = false
|
|
484
|
+
@url_fallback_base_url = nil
|
|
485
|
+
end
|
|
480
486
|
|
|
481
487
|
true
|
|
482
488
|
end
|
|
@@ -492,9 +498,16 @@ module Clacky
|
|
|
492
498
|
index = @models.find_index { |m| m["model"].to_s.downcase == name_str }
|
|
493
499
|
return false if index.nil?
|
|
494
500
|
|
|
501
|
+
previous_id = @current_model_id
|
|
495
502
|
@current_model_id = @models[index]["id"]
|
|
496
503
|
@current_model_index = index
|
|
497
504
|
|
|
505
|
+
if previous_id != @current_model_id
|
|
506
|
+
# Reset URL fallback when switching models — same rationale as switch_model_by_id.
|
|
507
|
+
@url_fallback_active = false
|
|
508
|
+
@url_fallback_base_url = nil
|
|
509
|
+
end
|
|
510
|
+
|
|
498
511
|
true
|
|
499
512
|
end
|
|
500
513
|
|
|
@@ -1053,6 +1066,63 @@ module Clacky
|
|
|
1053
1066
|
end
|
|
1054
1067
|
end
|
|
1055
1068
|
|
|
1069
|
+
# ── URL-level fallback ─────────────────────────────────────────────────
|
|
1070
|
+
# Independent from the model-name fallback above. When all max_retries
|
|
1071
|
+
# on the primary endpoint are exhausted, the caller may switch to a
|
|
1072
|
+
# secondary gateway URL (same model, different host) via these methods.
|
|
1073
|
+
# The URL fallback is intentionally one-shot per session — we do not
|
|
1074
|
+
# probe or reset it automatically, keeping the logic simple.
|
|
1075
|
+
|
|
1076
|
+
# Look up the fallback base URL for the current model's provider.
|
|
1077
|
+
# Returns nil if:
|
|
1078
|
+
# - provider cannot be determined
|
|
1079
|
+
# - provider is not openclacky (URL failover is only supported for the
|
|
1080
|
+
# OpenClacky gateway; other providers have no secondary endpoint)
|
|
1081
|
+
# - current base_url is already the fallback URL (user picked the
|
|
1082
|
+
# Secondary/China node directly — switching to the same URL does nothing)
|
|
1083
|
+
# @return [String, nil]
|
|
1084
|
+
def fallback_base_url_for_current_provider
|
|
1085
|
+
m = current_model
|
|
1086
|
+
return nil unless m
|
|
1087
|
+
|
|
1088
|
+
# Use strict URL matching to identify the provider — no inference.
|
|
1089
|
+
# resolve_provider would incorrectly return "openclacky" for localhost
|
|
1090
|
+
# addresses and clacky-* api keys, triggering unwanted failover.
|
|
1091
|
+
# find_by_base_url only matches registered preset URLs, so localhost
|
|
1092
|
+
# and other non-openclacky endpoints correctly return nil.
|
|
1093
|
+
provider_id = Clacky::Providers.find_by_base_url(m["base_url"])
|
|
1094
|
+
return nil unless provider_id == "openclacky"
|
|
1095
|
+
|
|
1096
|
+
fallback_url = Clacky::Providers.fallback_base_url(provider_id)
|
|
1097
|
+
return nil unless fallback_url
|
|
1098
|
+
|
|
1099
|
+
# Don't offer a fallback when the current base_url is already the
|
|
1100
|
+
# fallback endpoint — switching to the same URL accomplishes nothing.
|
|
1101
|
+
current_base = m["base_url"].to_s.chomp("/")
|
|
1102
|
+
return nil if current_base == fallback_url.to_s.chomp("/")
|
|
1103
|
+
|
|
1104
|
+
fallback_url
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
# Activate the URL fallback: override base_url with the secondary gateway.
|
|
1108
|
+
# Idempotent — safe to call multiple times.
|
|
1109
|
+
# @param fallback_url [String] the secondary gateway URL
|
|
1110
|
+
def activate_url_fallback!(fallback_url)
|
|
1111
|
+
@url_fallback_active = true
|
|
1112
|
+
@url_fallback_base_url = fallback_url
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
# Returns true when the secondary gateway URL is in use.
|
|
1116
|
+
def url_fallback_active?
|
|
1117
|
+
@url_fallback_active == true
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# The effective base URL for API calls.
|
|
1121
|
+
# Returns the URL-fallback override when active, otherwise the model's configured base_url.
|
|
1122
|
+
def effective_base_url
|
|
1123
|
+
@url_fallback_active ? @url_fallback_base_url : base_url
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1056
1126
|
# Get current model configuration.
|
|
1057
1127
|
#
|
|
1058
1128
|
# Resolution order:
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -40,6 +40,16 @@ module Clacky
|
|
|
40
40
|
# Grace period for offline heartbeat failures (3 days)
|
|
41
41
|
HEARTBEAT_GRACE_PERIOD = 3 * 86_400
|
|
42
42
|
|
|
43
|
+
# Per-slug locks serializing concurrent brand-skill installs. Each Agent
|
|
44
|
+
# spawns its own background sync thread, so several may try to install the
|
|
45
|
+
# same skill at once; a shared per-slug mutex collapses that to one download.
|
|
46
|
+
@install_locks = {}
|
|
47
|
+
@install_locks_guard = Mutex.new
|
|
48
|
+
|
|
49
|
+
def self.install_lock_for(slug)
|
|
50
|
+
@install_locks_guard.synchronize { @install_locks[slug] ||= Mutex.new }
|
|
51
|
+
end
|
|
52
|
+
|
|
43
53
|
attr_reader :product_name, :package_name, :license_key, :license_activated_at,
|
|
44
54
|
:license_expires_at, :license_last_heartbeat, :device_id,
|
|
45
55
|
:logo_url, :support_contact, :license_user_id,
|
|
@@ -1106,72 +1116,91 @@ module Clacky
|
|
|
1106
1116
|
|
|
1107
1117
|
require "zip"
|
|
1108
1118
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1119
|
+
# Serialize installs of the same slug so concurrent background syncs (one
|
|
1120
|
+
# per Agent) don't redundantly download the same ZIP or race on the same
|
|
1121
|
+
# destination directory.
|
|
1122
|
+
install_lock = self.class.install_lock_for(slug)
|
|
1123
|
+
install_lock.synchronize do
|
|
1124
|
+
dest_dir = File.join(brand_skills_dir, slug)
|
|
1125
|
+
|
|
1126
|
+
# Download and extract into a unique staging directory, never touching
|
|
1127
|
+
# dest_dir until everything succeeds. A failed or corrupt download must
|
|
1128
|
+
# never destroy the already-installed version.
|
|
1129
|
+
stage_id = "#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
1130
|
+
stage_dir = File.join(brand_skills_dir, ".staging-#{slug}-#{stage_id}")
|
|
1131
|
+
tmp_zip = File.join(brand_skills_dir, ".#{slug}-#{stage_id}.zip")
|
|
1132
|
+
FileUtils.mkdir_p(stage_dir)
|
|
1133
|
+
|
|
1134
|
+
begin
|
|
1135
|
+
# Download the zip file to a temp path via PlatformHttpClient so the
|
|
1136
|
+
# primary → fallback host failover applies uniformly to every download.
|
|
1137
|
+
dl = platform_client.download_file(url, tmp_zip)
|
|
1138
|
+
raise dl[:error].to_s unless dl[:success]
|
|
1139
|
+
|
|
1140
|
+
zip_size = File.size?(tmp_zip).to_i
|
|
1141
|
+
raise "Empty ZIP downloaded for #{slug}" if zip_size < 22 # min valid zip = empty central directory
|
|
1142
|
+
|
|
1143
|
+
# Extract into stage_dir.
|
|
1144
|
+
# Auto-detect whether the zip has a single root folder to strip.
|
|
1145
|
+
# Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x
|
|
1146
|
+
# path-safety restrictions on absolute destination paths.
|
|
1147
|
+
# Uses chunked read + size verification for robustness.
|
|
1148
|
+
Zip::File.open(tmp_zip) do |zip|
|
|
1149
|
+
entries = zip.entries.reject(&:directory?)
|
|
1150
|
+
top_dirs = entries.map { |e| e.name.split("/").first }.uniq
|
|
1151
|
+
has_root = top_dirs.length == 1 && entries.any? { |e| e.name.include?("/") }
|
|
1152
|
+
|
|
1153
|
+
entries.each do |entry|
|
|
1154
|
+
rel_path = if has_root
|
|
1155
|
+
parts = entry.name.split("/")
|
|
1156
|
+
parts[1..].join("/")
|
|
1157
|
+
else
|
|
1158
|
+
entry.name
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
next if rel_path.nil? || rel_path.empty?
|
|
1162
|
+
|
|
1163
|
+
out = File.join(stage_dir, rel_path)
|
|
1164
|
+
FileUtils.mkdir_p(File.dirname(out))
|
|
1165
|
+
|
|
1166
|
+
# Chunked copy with size verification
|
|
1167
|
+
written = 0
|
|
1168
|
+
File.open(out, "wb") do |f|
|
|
1169
|
+
entry.get_input_stream do |input|
|
|
1170
|
+
while (chunk = input.read(65536))
|
|
1171
|
+
f.write(chunk)
|
|
1172
|
+
written += chunk.bytesize
|
|
1173
|
+
end
|
|
1174
|
+
end
|
|
1175
|
+
end
|
|
1111
1176
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
dl = platform_client.download_file(url, tmp_zip)
|
|
1116
|
-
raise dl[:error].to_s unless dl[:success]
|
|
1117
|
-
|
|
1118
|
-
zip_size = File.size?(tmp_zip).to_i
|
|
1119
|
-
raise "Empty ZIP downloaded for #{slug}" if zip_size < 22 # min valid zip = empty central directory
|
|
1120
|
-
|
|
1121
|
-
# Extract into dest_dir (overwrite existing files).
|
|
1122
|
-
# Auto-detect whether the zip has a single root folder to strip.
|
|
1123
|
-
# Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x
|
|
1124
|
-
# path-safety restrictions on absolute destination paths.
|
|
1125
|
-
# Uses chunked read + size verification for robustness.
|
|
1126
|
-
Zip::File.open(tmp_zip) do |zip|
|
|
1127
|
-
entries = zip.entries.reject(&:directory?)
|
|
1128
|
-
top_dirs = entries.map { |e| e.name.split("/").first }.uniq
|
|
1129
|
-
has_root = top_dirs.length == 1 && entries.any? { |e| e.name.include?("/") }
|
|
1130
|
-
|
|
1131
|
-
entries.each do |entry|
|
|
1132
|
-
rel_path = if has_root
|
|
1133
|
-
parts = entry.name.split("/")
|
|
1134
|
-
parts[1..].join("/")
|
|
1135
|
-
else
|
|
1136
|
-
entry.name
|
|
1137
|
-
end
|
|
1138
|
-
|
|
1139
|
-
next if rel_path.nil? || rel_path.empty?
|
|
1140
|
-
|
|
1141
|
-
out = File.join(dest_dir, rel_path)
|
|
1142
|
-
FileUtils.mkdir_p(File.dirname(out))
|
|
1143
|
-
|
|
1144
|
-
# Chunked copy with size verification
|
|
1145
|
-
written = 0
|
|
1146
|
-
File.open(out, "wb") do |f|
|
|
1147
|
-
entry.get_input_stream do |input|
|
|
1148
|
-
while (chunk = input.read(65536))
|
|
1149
|
-
f.write(chunk)
|
|
1150
|
-
written += chunk.bytesize
|
|
1177
|
+
# Verify file size matches ZIP entry declaration
|
|
1178
|
+
if written != entry.size
|
|
1179
|
+
raise "Size mismatch for #{entry.name}: expected #{entry.size}, got #{written}"
|
|
1151
1180
|
end
|
|
1152
1181
|
end
|
|
1153
1182
|
end
|
|
1154
1183
|
|
|
1155
|
-
#
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1184
|
+
# Everything extracted successfully — atomically swap staging into
|
|
1185
|
+
# place. Only now is the previous version removed.
|
|
1186
|
+
FileUtils.rm_rf(dest_dir)
|
|
1187
|
+
FileUtils.mv(stage_dir, dest_dir)
|
|
1188
|
+
|
|
1189
|
+
record_installed_skill(slug, version, skill_info["description"],
|
|
1190
|
+
encrypted: encrypted,
|
|
1191
|
+
description_zh: skill_info["description_zh"],
|
|
1192
|
+
name_zh: skill_info["name_zh"])
|
|
1193
|
+
|
|
1194
|
+
{ success: true, name: slug, version: version }
|
|
1195
|
+
rescue StandardError, ScriptError => e
|
|
1196
|
+
# Only clean up our own staging artifacts; the installed version in
|
|
1197
|
+
# dest_dir is left untouched so a failed update never loses a skill.
|
|
1198
|
+
{ success: false, error: e.message }
|
|
1199
|
+
ensure
|
|
1200
|
+
FileUtils.rm_f(tmp_zip)
|
|
1201
|
+
FileUtils.rm_rf(stage_dir) if Dir.exist?(stage_dir)
|
|
1159
1202
|
end
|
|
1160
1203
|
end
|
|
1161
|
-
|
|
1162
|
-
FileUtils.rm_f(tmp_zip)
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
record_installed_skill(slug, version, skill_info["description"],
|
|
1166
|
-
encrypted: encrypted,
|
|
1167
|
-
description_zh: skill_info["description_zh"],
|
|
1168
|
-
name_zh: skill_info["name_zh"])
|
|
1169
|
-
|
|
1170
|
-
{ success: true, name: slug, version: version }
|
|
1171
|
-
rescue StandardError, ScriptError => e
|
|
1172
|
-
FileUtils.rm_f(tmp_zip) if defined?(tmp_zip) && tmp_zip
|
|
1173
|
-
FileUtils.rm_rf(dest_dir) if defined?(dest_dir) && dest_dir
|
|
1174
|
-
{ success: false, error: e.message }
|
|
1175
1204
|
end
|
|
1176
1205
|
|
|
1177
1206
|
# Install a mock brand skill for brand-test mode.
|
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
|
|
@@ -616,7 +623,7 @@ module Clacky
|
|
|
616
623
|
when 401 then I18n.t("llm.error.invalid_api_key")
|
|
617
624
|
when 403 then I18n.t("llm.error.403.#{error_code || "default"}")
|
|
618
625
|
when 404 then I18n.t("llm.error.endpoint_not_found")
|
|
619
|
-
when 429 then I18n.t("llm.error.rate_limit_429")
|
|
626
|
+
when 429 then error_code == "quota_exceeded" ? I18n.t("llm.error.quota_exhausted") : I18n.t("llm.error.rate_limit_429")
|
|
620
627
|
when 500..599 then I18n.t("llm.error.server_error", status: response.status)
|
|
621
628
|
else extract_error_message(error_body, response.body)
|
|
622
629
|
end
|
|
@@ -670,7 +677,11 @@ module Clacky
|
|
|
670
677
|
raise AgentError.new("[LLM] #{translated}", raw_message: error_message)
|
|
671
678
|
when 404
|
|
672
679
|
raise AgentError.new("[LLM] #{I18n.t("llm.error.endpoint_not_found")}", raw_message: error_message)
|
|
673
|
-
when 429
|
|
680
|
+
when 429
|
|
681
|
+
if error_code == "quota_exceeded"
|
|
682
|
+
raise AgentError.new("[LLM] #{I18n.t("llm.error.quota_exhausted")}", raw_message: error_message)
|
|
683
|
+
end
|
|
684
|
+
raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
|
|
674
685
|
when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
|
|
675
686
|
else raise AgentError.new("[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}", raw_message: error_message)
|
|
676
687
|
end
|
|
@@ -362,7 +362,7 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
362
362
|
post "/develop" do
|
|
363
363
|
idea = presence(json_body["idea"])
|
|
364
364
|
name = idea ? "扩展开发: #{idea[0, 40]}" : "扩展开发"
|
|
365
|
-
sid = create_session(name: name, prompt: idea, profile: "ext-developer"
|
|
365
|
+
sid = create_session(name: name, prompt: idea, profile: "ext-developer")
|
|
366
366
|
json(ok: true, session_id: sid)
|
|
367
367
|
end
|
|
368
368
|
|