openclacky 0.9.26 → 0.9.28

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +8 -4
  3. data/CHANGELOG.md +41 -0
  4. data/lib/clacky/agent/llm_caller.rb +110 -10
  5. data/lib/clacky/agent/skill_manager.rb +11 -4
  6. data/lib/clacky/agent.rb +16 -22
  7. data/lib/clacky/agent_config.rb +115 -1
  8. data/lib/clacky/brand_config.rb +182 -105
  9. data/lib/clacky/client.rb +12 -2
  10. data/lib/clacky/default_skills/browser-setup/SKILL.md +296 -71
  11. data/lib/clacky/default_skills/code-explorer/SKILL.md +1 -0
  12. data/lib/clacky/default_skills/new/SKILL.md +22 -2
  13. data/lib/clacky/default_skills/new/scripts/create_rails_project.sh +30 -33
  14. data/lib/clacky/default_skills/personal-website/publish.rb +59 -38
  15. data/lib/clacky/message_format/anthropic.rb +9 -1
  16. data/lib/clacky/message_format/bedrock.rb +4 -2
  17. data/lib/clacky/platform_http_client.rb +209 -0
  18. data/lib/clacky/providers.rb +39 -0
  19. data/lib/clacky/server/browser_manager.rb +66 -5
  20. data/lib/clacky/server/http_server.rb +132 -13
  21. data/lib/clacky/server/web_ui_controller.rb +42 -1
  22. data/lib/clacky/skill.rb +10 -7
  23. data/lib/clacky/skill_loader.rb +37 -5
  24. data/lib/clacky/tools/base.rb +3 -0
  25. data/lib/clacky/tools/browser.rb +3 -50
  26. data/lib/clacky/tools/file_reader.rb +9 -13
  27. data/lib/clacky/tools/glob.rb +5 -5
  28. data/lib/clacky/tools/grep.rb +1 -1
  29. data/lib/clacky/tools/safe_shell.rb +2 -2
  30. data/lib/clacky/tools/shell.rb +42 -42
  31. data/lib/clacky/ui2/ui_controller.rb +34 -30
  32. data/lib/clacky/ui_interface.rb +1 -0
  33. data/lib/clacky/utils/browser_detector.rb +73 -27
  34. data/lib/clacky/utils/file_processor.rb +122 -2
  35. data/lib/clacky/utils/scripts_manager.rb +1 -0
  36. data/lib/clacky/version.rb +1 -1
  37. data/lib/clacky/web/app.css +574 -4
  38. data/lib/clacky/web/app.js +198 -16
  39. data/lib/clacky/web/brand.js +66 -16
  40. data/lib/clacky/web/creator.js +418 -0
  41. data/lib/clacky/web/i18n.js +86 -0
  42. data/lib/clacky/web/index.html +98 -0
  43. data/lib/clacky/web/sessions.js +136 -16
  44. data/lib/clacky/web/settings.js +15 -2
  45. data/lib/clacky/web/skills.js +62 -177
  46. data/lib/clacky/web/ws.js +0 -1
  47. data/lib/clacky.rb +3 -0
  48. data/scripts/build/build.sh +329 -0
  49. data/scripts/build/lib/apt.sh +36 -0
  50. data/scripts/build/lib/brew.sh +89 -0
  51. data/scripts/build/lib/colors.sh +17 -0
  52. data/scripts/build/lib/gem.sh +95 -0
  53. data/scripts/build/lib/mise.sh +125 -0
  54. data/scripts/build/lib/network.sh +156 -0
  55. data/scripts/build/lib/os.sh +57 -0
  56. data/scripts/build/lib/shell.sh +37 -0
  57. data/scripts/build/src/install.sh.cc +164 -0
  58. data/scripts/build/src/install_browser.sh.cc +101 -0
  59. data/scripts/build/src/install_full.sh.cc +290 -0
  60. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  61. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  62. data/scripts/build/src/uninstall.sh.cc +101 -0
  63. data/scripts/install.sh +205 -307
  64. data/scripts/install_browser.sh +313 -114
  65. data/scripts/install_full.sh +528 -589
  66. data/scripts/install_rails_deps.sh +725 -0
  67. data/scripts/install_system_deps.sh +364 -128
  68. data/scripts/uninstall.sh +213 -89
  69. metadata +19 -2
  70. data/lib/clacky/default_skills/new/scripts/rails_env_checker.sh +0 -389
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65dad1ba4790fcffb30157bcc74a05a9f9cbd3e341ead0e007e6c768b0c11fd1
4
- data.tar.gz: f507ade251d206b073eb1b88c236d8905e9c986718d93c62cf3f2b575fa908bb
3
+ metadata.gz: 8e9d5f6bf142facc899ab2087b5f245e1e3b7aa5135548a42455fea73e685def
4
+ data.tar.gz: f3d3251db1a6245ff9bdbe37433c9e70409c1975c718f18a68b7af82d17a38a4
5
5
  SHA512:
6
- metadata.gz: f70df4500b95cd35c2a3fb245e384f94bbf4051da6528d6b9bd76a9a38e81838844cc68276ab0b7fc3833982252b9d61c1c5c3b414e22422049e2a91f2c24a4b
7
- data.tar.gz: 6cd678e1288d06f6997c01c056b898c900ba9b7fcde2bacdd3a35b113d0ab4d33d3e5e2c2fde9a31d1c9c351b615b93f28bcfa305eb7489b4deda1c1c801ce45
6
+ metadata.gz: a300098516c8081374fa6d55a21f4884f8d70b43c12b1283643a86e03fdb81d96f765a81bc14e703b6e4584bbe3fec1a7b08eadb5488ff5de2223fda922ca9e8
7
+ data.tar.gz: 64d73189f3852cf6fc064a61d065e762c6ba2e32d4d254736aebdcaff2106be2b55ea3ad349a1898778460e17142a06977250d8053f7db6bfe5525093f701b55
@@ -132,10 +132,13 @@ To use this skill, simply say:
132
132
 
133
133
  6. **Sync scripts/ to OSS**
134
134
 
135
- After updating latest.txt, sync all files in `scripts/` to OSS so users always get the latest install scripts:
135
+ After updating latest.txt, first rebuild all shell scripts from templates, then sync to OSS:
136
136
 
137
137
  ```bash
138
- # Upload each script file to OSS
138
+ # Step 1: Rebuild .sh files from .sh.cc templates
139
+ bash scripts/build/build.sh
140
+
141
+ # Step 2: Upload each script file to OSS
139
142
  for script in scripts/*; do
140
143
  coscli cp "$script" cos://clackyai-1258723534/clacky-ai/openclacky/main/scripts/$(basename "$script")
141
144
  done
@@ -144,7 +147,7 @@ To use this skill, simply say:
144
147
  curl -fsSL https://oss.1024code.com/clacky-ai/openclacky/main/scripts/install.sh | head -5
145
148
  ```
146
149
 
147
- This ensures `scripts/install.sh`, `scripts/install_simple.sh`, `scripts/install.ps1`, `scripts/uninstall.sh` and any future scripts are all mirrored on OSS.
150
+ This ensures `scripts/install.sh`, `scripts/install_simple.sh`, `scripts/install.ps1`, `scripts/uninstall.sh` and any future scripts are compiled from latest templates and mirrored on OSS.
148
151
 
149
152
  > **Prerequisite**: Same `coscli` setup as above
150
153
 
@@ -338,7 +341,8 @@ echo "X.Y.Z" > /tmp/latest.txt
338
341
  coscli cp /tmp/latest.txt cos://clackyai-1258723534/openclacky/latest.txt
339
342
  curl -fsSL https://oss.1024code.com/openclacky/latest.txt # verify
340
343
 
341
- # Sync scripts/ to OSS
344
+ # Sync scripts/ to OSS (build from templates first)
345
+ bash scripts/build/build.sh
342
346
  for script in scripts/*; do
343
347
  coscli cp "$script" cos://clackyai-1258723534/clacky-ai/openclacky/main/scripts/$(basename "$script")
344
348
  done
data/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.28] - 2026-04-10
11
+
12
+ ### Added
13
+ - **Creator menu**: new creator-focused UI for managing brand skills and customizations
14
+ - **Provider fallback system**: automatic fallback to secondary AI providers when primary provider fails
15
+ - **Chinese localization**: full UI translation for skill descriptions and session lists
16
+ - **Session scroll improvements**: better session navigation and scrolling behavior in Web UI
17
+ - **Brand logo support**: custom logos and icons for white-label deployments
18
+
19
+ ### Improved
20
+ - **Browser setup skill**: enhanced browser-setup SKILL with more detailed instructions and error handling
21
+ - **Browser port detection**: more robust detection logic for Chrome/Edge debugging port
22
+
23
+ ### More
24
+ - Test suite improvements and fixes
25
+
26
+ ## [0.9.27] - 2026-04-07
27
+
28
+ ### Added
29
+ - **Image understanding in file_reader**: the agent can now read and analyse images (PNG, JPG, GIF, WebP) by sending them to the vision API — just attach or reference an image file
30
+ - **Image auto-resize before upload**: large images are automatically resized to fit within model limits (max 5 MB base64), so vision requests no longer fail on high-resolution files
31
+ - **Rails project installer script**: new `install_rails_deps.sh` script sets up a complete Ruby on Rails development environment (Ruby, Bundler, Node, Yarn, PostgreSQL) in one command
32
+ - **Uninstall script**: new `scripts/uninstall.sh` to cleanly remove the openclacky gem and its associated files
33
+ - **Shell script build system**: `scripts/build/` now contains a template compiler (`.sh.cc` → `.sh`) with dependency checking — install scripts are generated from composable library modules
34
+ - **stdout streaming in Web UI**: agent tool output and shell results are now streamed live to the browser as they arrive, rather than waiting for a full response
35
+ - **Ctrl+O shortcut in CLI**: pressing Ctrl+O in the terminal UI opens a file/output viewer for the current session
36
+
37
+ ### Improved
38
+ - **Smart error recovery on 400 responses**: the agent now rolls back its message history when an API request is rejected as malformed (BadRequestError), preventing the same bad message from being replayed on every subsequent turn
39
+ - **Brand skill reliability**: brand skills now auto-retry on transient failures and fall back gracefully if the remote skill is temporarily unavailable
40
+ - **Shell tool RC file loading**: shell commands now correctly source `.bashrc` / `.zshrc` so user-defined aliases and environment variables are available inside tool executions
41
+ - **Shell UTF-8 encoding**: fixed a warning about character encoding when shell output contains non-ASCII characters
42
+
43
+ ### Fixed
44
+ - **Shell UTF-8 warning suppression**: eliminated noisy encoding warnings that appeared in shell tool output on some macOS setups
45
+
46
+ ### More
47
+ - Lite mode configuration groundwork for clackyai platform
48
+ - Rails new-project skill updated with improved environment checker
49
+ - `new` skill improvements: http_server integration and tool category support
50
+
10
51
  ## [0.9.26] - 2026-04-03
11
52
 
12
53
  ### Added
@@ -3,17 +3,46 @@
3
3
  module Clacky
4
4
  class Agent
5
5
  # LLM API call management
6
- # Handles API calls with retry logic and progress indication
6
+ # Handles API calls with retry logic, fallback model support, and progress indication
7
7
  module LlmCaller
8
- # Execute LLM API call with progress indicator, retry logic, and cost tracking
9
- # This method is shared by both normal think() and compression flows
8
+ # Number of consecutive RetryableError failures (503/429/5xx) before switching to fallback.
9
+ # Network-level errors (connection failures, timeouts) do NOT trigger fallback they are
10
+ # retried on the primary model for the full max_retries budget, since they are likely
11
+ # transient infrastructure blips rather than a model-level outage.
12
+ RETRIES_BEFORE_FALLBACK = 3
13
+
14
+ # After switching to the fallback model, allow this many retries before giving up.
15
+ # Kept lower than max_retries (10) because we have already exhausted the primary model.
16
+ MAX_RETRIES_ON_FALLBACK = 5
17
+
18
+ # Execute LLM API call with progress indicator, retry logic, and cost tracking.
19
+ #
20
+ # Fallback / probing state machine (driven by AgentConfig):
21
+ #
22
+ # :primary_ok (nil)
23
+ # Normal operation — use the configured model.
24
+ # After RETRIES_BEFORE_FALLBACK consecutive failures → :fallback_active
25
+ #
26
+ # :fallback_active
27
+ # Use fallback model. After FALLBACK_COOLING_OFF_SECONDS (30 min) the
28
+ # config transitions to :probing on the next call_llm entry.
29
+ #
30
+ # :probing
31
+ # Silently attempt the primary model once.
32
+ # Success → config transitions back to :primary_ok, user notified.
33
+ # Failure → renew cooling-off clock, back to :fallback_active, then
34
+ # retry the *same* request with the fallback model so the
35
+ # user experiences no extra delay.
36
+ #
10
37
  # @return [Hash] API response with :content, :tool_calls, :usage, etc.
11
38
  private def call_llm
39
+ # Transition :fallback_active → :probing if cooling-off has expired.
40
+ @config.maybe_start_probing
41
+
12
42
  @ui&.show_progress
13
43
 
14
44
  tools_to_send = @tool_registry.all_definitions
15
45
 
16
- # Retry logic for network failures
17
46
  max_retries = 10
18
47
  retry_delay = 5
19
48
  retries = 0
@@ -34,9 +63,23 @@ module Clacky
34
63
  max_tokens: @config.max_tokens,
35
64
  enable_caching: @config.enable_prompt_caching
36
65
  )
66
+
67
+ # Successful response — if we were probing, confirm primary is healthy.
68
+ handle_probe_success if @config.probing?
69
+
37
70
  rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
38
71
  @ui&.clear_progress
39
72
  retries += 1
73
+
74
+ # Probing failure: primary still down — renew cooling-off and retry with fallback.
75
+ if @config.probing?
76
+ handle_probe_failure
77
+ retry
78
+ end
79
+
80
+ # Network-level errors (timeouts, connection failures) are likely transient
81
+ # infrastructure blips — do NOT trigger fallback. Just retry on the current
82
+ # model (primary or already-active fallback) up to max_retries.
40
83
  if retries <= max_retries
41
84
  @ui&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
42
85
  sleep retry_delay
@@ -45,29 +88,86 @@ module Clacky
45
88
  @ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
46
89
  raise AgentError, "Network connection failed after #{max_retries} retries: #{e.message}"
47
90
  end
91
+
48
92
  rescue RetryableError => e
49
93
  @ui&.clear_progress
50
94
  retries += 1
51
- if retries <= max_retries
52
- @ui&.show_warning("#{e.message} (#{retries}/#{max_retries})")
95
+
96
+ # Probing failure: primary still down — renew cooling-off and retry with fallback.
97
+ if @config.probing?
98
+ handle_probe_failure
99
+ retry
100
+ end
101
+
102
+ # RetryableError (503/429/5xx/ThrottlingException) signals a service-level outage.
103
+ # After RETRIES_BEFORE_FALLBACK attempts, switch to the fallback model and reset the
104
+ # retry counter — but cap fallback retries at MAX_RETRIES_ON_FALLBACK (< max_retries)
105
+ # since we have already confirmed the primary is struggling.
106
+ current_max = @config.fallback_active? ? MAX_RETRIES_ON_FALLBACK : max_retries
107
+
108
+ if retries <= current_max
109
+ if retries == RETRIES_BEFORE_FALLBACK && !@config.fallback_active?
110
+ if try_activate_fallback(current_model)
111
+ retries = 0
112
+ retry
113
+ end
114
+ end
115
+ @ui&.show_warning("#{e.message} (#{retries}/#{current_max})")
53
116
  sleep retry_delay
54
117
  retry
55
118
  else
56
- @ui&.show_error("LLM service unavailable after #{max_retries} retries. Please try again later.")
57
- raise AgentError, "LLM service unavailable after #{max_retries} retries"
119
+ @ui&.show_error("LLM service unavailable after #{current_max} retries. Please try again later.")
120
+ raise AgentError, "LLM service unavailable after #{current_max} retries"
58
121
  end
122
+
59
123
  ensure
60
124
  @ui&.clear_progress
61
125
  end
62
126
 
63
127
  # Track cost and collect token usage data.
64
- # token_data is returned to the caller so it can be displayed
65
- # after show_assistant_message (ensuring correct ordering in WebUI).
66
128
  token_data = track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
67
129
  response[:token_usage] = token_data
68
130
 
69
131
  response
70
132
  end
133
+
134
+ # Attempt to activate the provider fallback model for the given primary model.
135
+ # Shows a user-visible warning when switching. Returns true if a fallback was found
136
+ # and activated, false if no fallback is configured.
137
+ # @param failed_model [String] the model name that is currently failing
138
+ # @return [Boolean]
139
+ private def try_activate_fallback(failed_model)
140
+ fallback = @config.fallback_model_for(failed_model)
141
+ return false unless fallback
142
+
143
+ @config.activate_fallback!(fallback)
144
+ @ui&.show_warning(
145
+ "Model #{failed_model} appears unavailable. " \
146
+ "Automatically switching to fallback model: #{fallback}"
147
+ )
148
+ true
149
+ end
150
+
151
+ # Called when a probe attempt (testing primary after cooling-off) succeeds.
152
+ # Resets the state machine to :primary_ok and notifies the user.
153
+ private def handle_probe_success
154
+ primary = @config.model_name
155
+ @config.confirm_fallback_ok!
156
+ @ui&.show_warning("Primary model #{primary} is healthy again. Switched back automatically.")
157
+ end
158
+
159
+ # Called when a probe attempt fails.
160
+ # Renews the cooling-off clock (back to :fallback_active) so the *same*
161
+ # request is immediately retried with the fallback model — no extra delay.
162
+ private def handle_probe_failure
163
+ fallback = @config.instance_variable_get(:@fallback_model)
164
+ primary = @config.model_name
165
+ @config.activate_fallback!(fallback) # renews @fallback_since
166
+ @ui&.show_warning(
167
+ "Primary model #{primary} still unavailable. " \
168
+ "Continuing with fallback model: #{fallback}"
169
+ )
170
+ end
71
171
  end
72
172
  end
73
173
  end
@@ -379,9 +379,6 @@ module Clacky
379
379
  # @param arguments [String] Arguments for the skill
380
380
  # @return [String] Summary of subagent execution
381
381
  def execute_skill_with_subagent(skill, arguments)
382
- # Log subagent fork
383
- @ui&.show_info("Subagent start: #{skill.identifier}")
384
-
385
382
  # For encrypted brand skills with supporting scripts: decrypt to a tmpdir.
386
383
  # Subagent path has a clear boundary (subagent.run returns), so we shred inline
387
384
  # rather than registering on the parent agent.
@@ -406,6 +403,10 @@ module Clacky
406
403
  system_prompt_suffix: skill_instructions
407
404
  )
408
405
 
406
+ # Log which model the subagent is actually using (may differ from requested
407
+ # when "lite" falls back to default due to no lite model configured)
408
+ @ui&.show_info("Subagent start: #{skill.identifier} [#{subagent.current_model_info[:model]}]")
409
+
409
410
  # Run subagent with the actual task as the sole user turn.
410
411
  # If the user typed the skill command with no arguments (e.g. "/jade-appraisal"),
411
412
  # use a generic trigger phrase so the user message is never empty.
@@ -446,8 +447,14 @@ module Clacky
446
447
  m[:skill_name] = skill.identifier
447
448
  end
448
449
 
450
+ # Merge subagent cost into parent agent's total so the sessionbar reflects
451
+ # the real cumulative spend across all subagents
452
+ subagent_cost = result[:total_cost_usd] || 0.0
453
+ @total_cost += subagent_cost
454
+ @ui&.update_sessionbar(cost: @total_cost)
455
+
449
456
  # Log completion
450
- @ui&.show_info("Subagent completed: #{result[:iterations]} iterations, $#{result[:total_cost_usd].round(4)}")
457
+ @ui&.show_info("Subagent completed: #{result[:iterations]} iterations, $#{subagent_cost.round(4)} (total: $#{@total_cost.round(4)})")
451
458
 
452
459
  # Return summary as the skill execution result
453
460
  summary
data/lib/clacky/agent.rb CHANGED
@@ -159,9 +159,9 @@ module Clacky
159
159
  }
160
160
  end
161
161
 
162
- # Get current model name
162
+ # Get current model name (respects any active fallback override)
163
163
  private def current_model
164
- @config.model_name
164
+ @config.effective_model_name
165
165
  end
166
166
 
167
167
  # Rename this session. Called by auto-naming (first message) or user explicit rename.
@@ -432,6 +432,11 @@ module Clacky
432
432
  }
433
433
  Clacky::Logger.error("agent_run_error", error: e)
434
434
 
435
+ # 400 errors mean our request was malformed — roll back history so the bad
436
+ # message is not replayed on the next user turn.
437
+ # Other errors (auth, network, etc.) leave history intact for retry.
438
+ @pending_error_rollback = true if e.is_a?(Clacky::BadRequestError)
439
+
435
440
  # Build error result for session data, but let CLI handle error display
436
441
  result = build_result(:error, error: e.message) # rubocop:disable Lint/UselessAssignment
437
442
  raise
@@ -647,35 +652,24 @@ module Clacky
647
652
  # Automatic progress display after 2 seconds for any tool execution
648
653
  progress_shown = false
649
654
  progress_timer = nil
650
- output_buffer = nil
651
655
 
652
656
  if @ui
653
657
  progress_message = build_tool_progress_message(call[:name], args)
654
658
 
655
- # For shell commands, create shared output buffer
656
- if call[:name] == "shell" || call[:name] == "safe_shell"
657
- output_buffer = { content: "", timestamp: Time.now }
658
- args[:output_buffer] = output_buffer
659
+ # For shell/safe_shell: inject on_output callback for real-time stdout streaming.
660
+ # The callback fires immediately on each read_nonblock chunk — no polling delay.
661
+ if (call[:name] == "shell" || call[:name] == "safe_shell") &&
662
+ @ui.respond_to?(:show_tool_stdout)
663
+ args[:on_output] = ->(stream, data) {
664
+ @ui.show_tool_stdout([data]) if stream == :stdout
665
+ }
659
666
  end
660
667
 
661
668
  progress_timer = Thread.new do
662
669
  sleep 2
663
- @ui.show_progress(progress_message, prefix_newline: false, output_buffer: output_buffer)
670
+ @ui.show_progress(progress_message, prefix_newline: false)
664
671
  progress_shown = true
665
-
666
- # For shell commands: stream new stdout lines to WebUI as they arrive
667
- if output_buffer && @ui.respond_to?(:show_tool_stdout)
668
- last_sent_count = 0
669
- loop do
670
- sleep 1
671
- stdout_lines = output_buffer[:stdout_lines]&.to_a || []
672
- new_lines = stdout_lines[last_sent_count..]
673
- if new_lines && !new_lines.empty?
674
- @ui.show_tool_stdout(new_lines)
675
- last_sent_count = stdout_lines.size
676
- end
677
- end
678
- end
672
+ # Streaming is handled by on_output callback — no polling loop needed here
679
673
  end
680
674
  end
681
675
 
@@ -222,9 +222,45 @@ module Clacky
222
222
  end
223
223
  end
224
224
 
225
+ # Auto-inject lite model from provider preset when:
226
+ # 1. A default model exists
227
+ # 2. No lite model is configured yet (neither in file nor env)
228
+ # 3. The default model's provider has a known lite_model
229
+ # The injected lite model is runtime-only (not persisted to config.yml)
230
+ inject_provider_lite_model(models)
231
+
225
232
  new(models: models)
226
233
  end
227
234
 
235
+ # Auto-inject a lite model entry if the default model's provider supports one
236
+ # and no lite model is already present. The injected entry reuses the same
237
+ # api_key and base_url as the default model — only the model name differs.
238
+ # @param models [Array<Hash>] mutable models array (modified in-place)
239
+ private_class_method def self.inject_provider_lite_model(models)
240
+ return if models.any? { |m| m["type"] == "lite" }
241
+
242
+ default_model = models.find { |m| m["type"] == "default" } || models.first
243
+ return unless default_model
244
+
245
+ provider_id = Clacky::Providers.find_by_base_url(default_model["base_url"])
246
+ return unless provider_id
247
+
248
+ lite_model_name = Clacky::Providers.lite_model(provider_id)
249
+ return unless lite_model_name
250
+
251
+ # Don't inject if the default model IS the lite model
252
+ return if default_model["model"] == lite_model_name
253
+
254
+ models << {
255
+ "api_key" => default_model["api_key"],
256
+ "base_url" => default_model["base_url"],
257
+ "model" => lite_model_name,
258
+ "anthropic_format" => default_model["anthropic_format"] || false,
259
+ "type" => "lite",
260
+ "auto_injected" => true # Mark as auto-injected (not saved to file)
261
+ }
262
+ end
263
+
228
264
  # Save configuration to file
229
265
  # Deep copy — models array contains mutable Hashes, so a shallow dup would
230
266
  # let the copy share the same Hash objects with the original, causing
@@ -244,8 +280,11 @@ module Clacky
244
280
  end
245
281
 
246
282
  # Convert to YAML format (top-level array)
283
+ # Auto-injected lite models (auto_injected: true) are excluded from persistence —
284
+ # they are regenerated at load time from the provider preset.
247
285
  def to_yaml
248
- YAML.dump(@models)
286
+ persistable = @models.reject { |m| m["auto_injected"] }
287
+ YAML.dump(persistable)
249
288
  end
250
289
 
251
290
  # Check if any model is configured
@@ -359,6 +398,81 @@ module Clacky
359
398
  find_model_by_type("lite")
360
399
  end
361
400
 
401
+ # How long to stay on the fallback model before probing the primary again.
402
+ FALLBACK_COOLING_OFF_SECONDS = 30 * 60 # 30 minutes
403
+
404
+ # Look up the fallback model name for the given model name.
405
+ # Uses the provider preset's fallback_models table.
406
+ # Returns nil if no fallback is configured for this model.
407
+ # @param model_name [String] the primary model name (e.g. "abs-claude-sonnet-4-6")
408
+ # @return [String, nil]
409
+ def fallback_model_for(model_name)
410
+ m = current_model
411
+ return nil unless m
412
+
413
+ provider_id = Clacky::Providers.find_by_base_url(m["base_url"])
414
+ return nil unless provider_id
415
+
416
+ Clacky::Providers.fallback_model(provider_id, model_name)
417
+ end
418
+
419
+ # Switch to fallback model and start the cooling-off clock.
420
+ # Idempotent — calling again while already in :fallback_active renews the timestamp.
421
+ # @param fallback_model_name [String] the fallback model to use
422
+ def activate_fallback!(fallback_model_name)
423
+ @fallback_state = :fallback_active
424
+ @fallback_since = Time.now
425
+ @fallback_model = fallback_model_name
426
+ end
427
+
428
+ # Called at the start of every call_llm.
429
+ # If cooling-off has expired, transition from :fallback_active → :probing
430
+ # so the next request will silently test the primary model.
431
+ # No-op in any other state.
432
+ def maybe_start_probing
433
+ return unless @fallback_state == :fallback_active
434
+ return unless @fallback_since && (Time.now - @fallback_since) >= FALLBACK_COOLING_OFF_SECONDS
435
+
436
+ @fallback_state = :probing
437
+ end
438
+
439
+ # Called when a successful API response is received.
440
+ # If we were :probing (testing primary after cooling-off), this confirms
441
+ # the primary model is healthy again and resets everything.
442
+ # No-op in :primary_ok or :fallback_active states.
443
+ def confirm_fallback_ok!
444
+ return unless @fallback_state == :probing
445
+
446
+ @fallback_state = nil
447
+ @fallback_since = nil
448
+ @fallback_model = nil
449
+ end
450
+
451
+ # Returns true when a fallback model is currently being used
452
+ # (:fallback_active or :probing states).
453
+ def fallback_active?
454
+ @fallback_state == :fallback_active || @fallback_state == :probing
455
+ end
456
+
457
+ # Returns true only when we are silently probing the primary model.
458
+ def probing?
459
+ @fallback_state == :probing
460
+ end
461
+
462
+ # The effective model name to use for API calls.
463
+ # - :primary_ok / nil → configured model_name (primary)
464
+ # - :fallback_active → fallback model
465
+ # - :probing → configured model_name (trying primary silently)
466
+ def effective_model_name
467
+ case @fallback_state
468
+ when :fallback_active
469
+ @fallback_model || model_name
470
+ else
471
+ # :primary_ok (nil) and :probing both use the primary model
472
+ model_name
473
+ end
474
+ end
475
+
362
476
  # Get current model configuration
363
477
  # Looks for type: default first, falls back to current_model_index
364
478
  def current_model