Class: Clacky::AgentConfig

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/agent_config.rb

Constant Summary collapse

CONFIG_DIR =
File.join(Dir.home, ".clacky")
CONFIG_FILE =
File.join(CONFIG_DIR, "config.yml")
CLAUDE_DEFAULT_MODEL =

Default model for ClaudeCode environment

"claude-sonnet-4-5"
PERMISSION_MODES =
[:auto_approve, :confirm_safes, :confirm_all].freeze
DEFAULT_COMPRESSION_THRESHOLD =

Conversation-history compression defaults. Both are user-configurable via ‘settings.compression_threshold` / `settings.message_count_threshold` in ~/.clacky/config.yml — local-model users (llama.cpp / ollama / vllm) typically lower compression_threshold to fit their server’s context window.

150_000
DEFAULT_MESSAGE_COUNT_THRESHOLD =
200
RUNTIME_ONLY_FIELDS =

Convert to YAML format (top-level array) Auto-injected lite models (auto_injected: true) are excluded from persistence —they are regenerated at load time from the provider preset. Runtime-only fields (id, auto_injected) are stripped before writing so config.yml remains backward compatible with users on older versions.

%w[id auto_injected].freeze
CONFIG_SETTINGS_KEYS =

Settings keys that are persisted to config.yml. These map directly to AgentConfig accessors.

%w[
  enable_compression enable_prompt_caching
  compression_threshold message_count_threshold
  memory_update_enabled
  skill_evolution max_running_agents max_idle_agents
  default_working_dir
  proxy_url
  media_output_dir
].freeze
FALLBACK_COOLING_OFF_SECONDS =

How long to stay on the fallback model before probing the primary again.

30 * 60

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ AgentConfig

Returns a new instance of AgentConfig.



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/clacky/agent_config.rb', line 171

def initialize(options = {})
  @permission_mode = validate_permission_mode(options[:permission_mode])
  @max_tokens = options[:max_tokens] || 16384
  @verbose = options[:verbose] || false
  @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
  # Enable prompt caching by default for cost savings
  @enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]
  # Token threshold that triggers proactive history compression. Local models
  # (llama.cpp, ollama, vllm) often have small context windows (e.g. 64k);
  # users with such setups should lower this to avoid hitting the server-side ceiling.
  @compression_threshold = options[:compression_threshold] || DEFAULT_COMPRESSION_THRESHOLD
  # Message-count threshold that also triggers compression, independent of token count.
  # Guards against pathological histories with many tiny messages.
  @message_count_threshold = options[:message_count_threshold] || DEFAULT_MESSAGE_COUNT_THRESHOLD

  # Models configuration
  @models = options[:models] || []
  # Ensure every model has a stable runtime id — this is the single
  # invariant the rest of the system relies on. Regardless of how the
  # config was built (load from yml, direct .new in tests, add_model,
  # api_save_config), every model in @models will have an id.
  @models.each { |m| m["id"] ||= SecureRandom.uuid }

  @current_model_index = options[:current_model_index] || 0
  # Stable runtime id for the currently-selected model. Preferred over
  # @current_model_index because ids are immune to list reordering,
  # additions, and edits to model fields. Ids are injected at load time
  # and never persisted to config.yml (backward compatible with old files).
  # If caller didn't specify current_model_id, prefer the model marked
  # as `type: default` (the documented convention), falling back to
  # models[current_model_index] only if no default marker exists.
  @current_model_id = options[:current_model_id] ||
                      (@models.find { |m| m["type"] == "default" } || @models[@current_model_index])&.dig("id")

  # Memory and skill evolution configuration
  @memory_update_enabled = options[:memory_update_enabled].nil? ? true : options[:memory_update_enabled]
  @skill_evolution = options[:skill_evolution] || {
    enabled: true,
    auto_create_threshold: 12,
    reflection_mode: "llm_analysis"
  }
  # Deep-symbolize keys — YAML-loaded hashes come with string keys,
  # but the rest of the codebase accesses with symbols.
  @skill_evolution = @skill_evolution.transform_keys(&:to_sym)
  @skill_evolution.transform_values! { |v| v.is_a?(Hash) ? v.transform_keys(&:to_sym) : v }

  @max_running_agents = options[:max_running_agents] || 10
  @max_idle_agents = options[:max_idle_agents] || 10

  @default_working_dir = options[:default_working_dir] || ENV["CLACKY_WORKSPACE_DIR"]

  # HTTP proxy policy. The user's shell ENV (HTTP_PROXY etc.) is always
  # ignored — set proxy_url here to route Clacky's outbound HTTP through
  # a proxy. Leave nil to go direct.
  @proxy_url = options[:proxy_url]

  # User-configured directory where generated images / videos / audio
  # land when a /api/media/* call doesn't pass an explicit output_dir.
  # Final on-disk path is `<media_output_dir>/assets/generated/<file>`
  # (the `assets/generated/` suffix is fixed by Media::Base for stable
  # markdown/relative-path semantics across docs).
  # Leave nil → fall back to Dir.pwd (legacy behavior, preserved for
  # older configs that have no key set).
  @media_output_dir = options[:media_output_dir]

  # Per-session virtual model overlay.
  # When set, #current_model returns a *merged* hash (the resolved @models
  # entry merged with this overlay) without mutating the shared @models
  # array. Used by fork_subagent's virtual-lite path so a forked subagent
  # can run on different credentials (e.g. Haiku instead of Opus) without
  # polluting the parent agent's shared @models hashes.
  # Keys honored: "api_key", "base_url", "model", "anthropic_format".
  # @return [Hash, nil]
  @virtual_model_overlay = options[:virtual_model_overlay]

  # Per-session sub-model override. Persists across restarts via the
  # session file. Independent of @virtual_model_overlay (which is for
  # short-lived subagent forks). Used by the WebUI sub-model switcher
  # to pin a session to e.g. "dsk-deepseek-v4-pro" while the underlying
  # card still says "abs-claude-sonnet-4-6". Only the "model" key is
  # honored — sub-model switching never changes credentials.
  @session_model_overlay = options[:session_model_overlay]
end

Instance Attribute Details

#compression_thresholdObject

Returns the value of attribute compression_threshold.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def compression_threshold
  @compression_threshold
end

#current_model_idObject

Returns the value of attribute current_model_id.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def current_model_id
  @current_model_id
end

#current_model_indexObject

Returns the value of attribute current_model_index.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def current_model_index
  @current_model_index
end

#default_working_dirObject

Returns the value of attribute default_working_dir.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def default_working_dir
  @default_working_dir
end

#enable_compressionObject

Returns the value of attribute enable_compression.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def enable_compression
  @enable_compression
end

#enable_prompt_cachingObject

Returns the value of attribute enable_prompt_caching.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def enable_prompt_caching
  @enable_prompt_caching
end

#max_idle_agentsObject

Returns the value of attribute max_idle_agents.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def max_idle_agents
  @max_idle_agents
end

#max_running_agentsObject

Returns the value of attribute max_running_agents.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def max_running_agents
  @max_running_agents
end

#max_tokensObject

Returns the value of attribute max_tokens.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def max_tokens
  @max_tokens
end

#media_output_dirObject

Returns the value of attribute media_output_dir.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def media_output_dir
  @media_output_dir
end

#memory_update_enabledObject

Returns the value of attribute memory_update_enabled.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def memory_update_enabled
  @memory_update_enabled
end

#message_count_thresholdObject

Returns the value of attribute message_count_threshold.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def message_count_threshold
  @message_count_threshold
end

#modelsObject

Returns the value of attribute models.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def models
  @models
end

#permission_modeObject

Returns the value of attribute permission_mode.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def permission_mode
  @permission_mode
end

#proxy_urlObject

Returns the value of attribute proxy_url.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def proxy_url
  @proxy_url
end

#skill_evolutionObject

Returns the value of attribute skill_evolution.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def skill_evolution
  @skill_evolution
end

#verboseObject

Returns the value of attribute verbose.



161
162
163
# File 'lib/clacky/agent_config.rb', line 161

def verbose
  @verbose
end

Class Method Details

.load(config_file = CONFIG_FILE) ⇒ Object

Load configuration from file



256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/clacky/agent_config.rb', line 256

def self.load(config_file = CONFIG_FILE)
  # Load from config file first
  if File.exist?(config_file)
    data = YAML.load_file(config_file)
  else
    data = nil
  end

  # Extract settings from hash-format config (new format).
  # Old flat-array configs have no settings section — all defaults.
  loaded_settings = {}
  if data.is_a?(Hash) && data["settings"].is_a?(Hash)
    loaded_settings = data["settings"]
  end

  # Parse models from config
  models = parse_models(data)

  # Priority: config file > CLACKY_XXX env vars > ClaudeCode env vars
  if models.empty?
    # Try CLACKY_XXX environment variables first
    if ClackyEnv.default_configured?
      models << ClackyEnv.default_model_config
    # ClaudeCode (Anthropic) environment variable support is disabled
    # elsif ClaudeCodeEnv.configured?
    #   models << {
    #     "type" => "default",
    #     "api_key" => ClaudeCodeEnv.api_key,
    #     "base_url" => ClaudeCodeEnv.base_url,
    #     "model" => CLAUDE_DEFAULT_MODEL,
    #     "anthropic_format" => true
    #   }
    end

    # Add CLACKY_LITE_XXX if configured (only when loading from env)
    if ClackyEnv.lite_configured?
      models << ClackyEnv.lite_model_config
    end
  else
    # Config file exists, but check if we need to add env-based models
    # Only add if no model with that type exists
    has_default = models.any? { |m| m["type"] == "default" }
    has_lite = models.any? { |m| m["type"] == "lite" }

    # Add CLACKY default if not in config and env is set
    if !has_default && ClackyEnv.default_configured?
      models << ClackyEnv.default_model_config
    end

    # Add CLACKY lite if not in config and env is set
    if !has_lite && ClackyEnv.lite_configured?
      models << ClackyEnv.lite_model_config
    end

    # Ensure at least one model has type: default
    # If no model has type: default, assign it to the first model
    unless models.any? { |m| m["type"] == "default" }
      models.first["type"] = "default" if models.any?
    end
  end

  # Auto-inject lite model from provider preset is **no longer materialized
  # into @models**. Lite is now a virtual, on-demand view derived from the
  # currently-selected primary model — see `#lite_model_config_for_current`.
  # This keeps @models a clean "list of user-facing models" and lets the
  # lite companion track the current model at runtime, rather than being
  # frozen at load time to whichever model happened to be the default.
  #
  # Legacy note: prior versions injected an entry here with
  # `auto_injected: true`. That flag is still honored in to_yaml for
  # safety (never persisted), but new injections never happen.

  # Ensure every model has a stable runtime id — covers env-injected
  # models (CLACKY_XXX, CLAUDE_XXX) that don't go through parse_models.
  # Ids are NOT persisted to config.yml (see to_yaml).
  models.each { |m| m["id"] ||= SecureRandom.uuid }

  # Find the index of the model marked as "default" (type: default)
  # Fall back to 0 if no model has type: default
  default_index = models.find_index { |m| m["type"] == "default" } || 0
  default_id = models[default_index] && models[default_index]["id"]

  # Build constructor args from loaded settings (new hash-format config)
  # plus the parsed models. Only pass settings that have explicit values;
  # omitted keys get their default from AgentConfig#initialize.
  constructor_args = {
    models: models,
    current_model_index: default_index,
    current_model_id: default_id
  }
  CONFIG_SETTINGS_KEYS.each do |key|
    if loaded_settings.key?(key)
      constructor_args[key.to_sym] = loaded_settings[key]
    end
  end

  instance = new(**constructor_args)
  instance.derive_media_models!
  instance
end

Instance Method Details

#activate_fallback!(fallback_model_name) ⇒ Object

Switch to fallback model and start the cooling-off clock. Idempotent — calling again while already in :fallback_active renews the timestamp.

Parameters:

  • fallback_model_name (String)

    the fallback model to use



1014
1015
1016
1017
1018
# File 'lib/clacky/agent_config.rb', line 1014

def activate_fallback!(fallback_model_name)
  @fallback_state = :fallback_active
  @fallback_since = Time.now
  @fallback_model  = fallback_model_name
end

#add_model(model:, api_key:, base_url:, anthropic_format: false, type: nil) ⇒ Object

Add a new model configuration



619
620
621
622
623
624
625
626
627
628
# File 'lib/clacky/agent_config.rb', line 619

def add_model(model:, api_key:, base_url:, anthropic_format: false, type: nil)
  @models << {
    "id" => SecureRandom.uuid,
    "api_key" => api_key,
    "base_url" => base_url,
    "model" => model,
    "anthropic_format" => anthropic_format,
    "type" => type
  }.compact
end

#anthropic_format?Boolean

Check if should use Anthropic format for current model

Returns:

  • (Boolean)


609
610
611
# File 'lib/clacky/agent_config.rb', line 609

def anthropic_format?
  current_model&.dig("anthropic_format") || false
end

#api_keyObject

Get API key for current model



562
563
564
# File 'lib/clacky/agent_config.rb', line 562

def api_key
  current_model&.dig("api_key")
end

#api_key=(value) ⇒ Object

Set API key for current model. When a virtual overlay is active, writes into the overlay (not the shared @models hash) to keep session-level isolation.



569
570
571
572
573
574
575
576
# File 'lib/clacky/agent_config.rb', line 569

def api_key=(value)
  return unless resolve_current_model_entry
  if @virtual_model_overlay
    @virtual_model_overlay["api_key"] = value
  else
    resolve_current_model_entry["api_key"] = value
  end
end

#apply_virtual_model_overlay!(overlay) ⇒ void

This method returns an undefined value.

Apply a virtual model overlay for this session (and only this session). The overlay fields are merged on top of the current model entry when #current_model is called, without ever mutating the shared @models array or its hashes.

Used by Agent#fork_subagent when routing a subagent through a virtual lite model (Haiku for Claude family, Flash for DeepSeek, …). Apply on the forked config only — the parent config is untouched.

Parameters:

  • overlay (Hash, nil)

    fields to overlay; pass nil or {} to clear. Recognized keys: “api_key”, “base_url”, “model”, “anthropic_format”.



1132
1133
1134
1135
1136
1137
1138
1139
# File 'lib/clacky/agent_config.rb', line 1132

def apply_virtual_model_overlay!(overlay)
  if overlay.nil? || overlay.empty?
    @virtual_model_overlay = nil
  else
    # Dup so later mutations to the passed-in hash don't leak in.
    @virtual_model_overlay = overlay.dup
  end
end

#base_urlObject

Get base URL for current model



579
580
581
# File 'lib/clacky/agent_config.rb', line 579

def base_url
  current_model&.dig("base_url")
end

#base_url=(value) ⇒ Object

Set base URL for current model (overlay-aware; see #api_key=).



584
585
586
587
588
589
590
591
# File 'lib/clacky/agent_config.rb', line 584

def base_url=(value)
  return unless resolve_current_model_entry
  if @virtual_model_overlay
    @virtual_model_overlay["base_url"] = value
  else
    resolve_current_model_entry["base_url"] = value
  end
end

#bedrock?Boolean

Check if current model uses Bedrock Converse API (ABSK key prefix or abs- model prefix)

Returns:

  • (Boolean)


614
615
616
# File 'lib/clacky/agent_config.rb', line 614

def bedrock?
  Clacky::MessageFormat::Bedrock.bedrock_api_key?(api_key.to_s, model_name.to_s)
end

#confirm_fallback_ok!Object

Called when a successful API response is received. If we were :probing (testing primary after cooling-off), this confirms the primary model is healthy again and resets everything. No-op in :primary_ok or :fallback_active states.



1035
1036
1037
1038
1039
1040
1041
# File 'lib/clacky/agent_config.rb', line 1035

def confirm_fallback_ok!
  return unless @fallback_state == :probing

  @fallback_state = nil
  @fallback_since = nil
  @fallback_model = nil
end

#current_modelObject

Get current model configuration.

Resolution order:

1. @current_model_id (primary source of truth — stable across list edits)
2. type: default (for config.yml that sets a default explicitly)
3. @current_model_index (back-compat for very old code paths)


1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
# File 'lib/clacky/agent_config.rb', line 1074

def current_model
  return nil if @models.empty?

  resolved = resolve_current_model_entry
  return nil unless resolved

  # Merge order (low → high): base entry, session-level sub-model override,
  # then short-lived subagent overlay. Both layers are kept separate so
  # a subagent fork can stack its own credentials on top of an active
  # sub-model pin without erasing it.
  merged = resolved
  if @session_model_overlay && !@session_model_overlay.empty?
    merged = merged.merge(@session_model_overlay)
  end
  if @virtual_model_overlay && !@virtual_model_overlay.empty?
    merged = merged.merge(@virtual_model_overlay)
  end
  merged.equal?(resolved) ? resolved : merged
end

#current_model_supports?(capability) ⇒ Boolean

Query whether the current model supports a given capability.

This is the single entry-point callers (Agent, downgrade pipeline, UI) should use instead of poking Providers directly. Benefits:

- Always reflects the current model — switching with `/model` takes
  effect immediately, no caching, no stale warnings.
- Handles the "custom base_url / unknown provider" case with a
  conservative default (assume supported), so self-hosted or new
  providers don't get accidentally downgraded.

Parameters:

  • capability (String, Symbol)

    capability name (e.g. :vision)

Returns:

  • (Boolean)

    true if supported (or unknown); false only when the preset explicitly declares the capability as unsupported.



1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
# File 'lib/clacky/agent_config.rb', line 1189

def current_model_supports?(capability)
  m = current_model
  # No model configured yet → nothing to judge; assume supported so we
  # don't preemptively downgrade before a model is even picked.
  return true unless m && m["base_url"]

  provider_id = Clacky::Providers.find_by_base_url(m["base_url"])
  # Custom / self-hosted base_url not in our preset list → be conservative.
  return true unless provider_id

  Clacky::Providers.supports?(provider_id, capability, model_name: m["model"])
end

#deep_copyObject

Create a per-session copy of this config.

Plan B (shared models): we deliberately share the SAME @models array reference with all sessions (no deep clone). This is the key design decision that keeps session and global views in sync:

- User adds a model in Settings → every live session sees it instantly.
- User edits api_key/base_url → every live session's next API call
  picks up the new credentials (via current_model lookup).
- Model ids are stable across edits, so each session's
  @current_model_id continues to resolve correctly.

Per-session state that MUST stay isolated (permission_mode, @current_model_id, @current_model_index, fallback state) are scalar copies via ‘dup` and don’t leak between sessions.

Before Plan B, sessions held deep-copied @models — which silently diverged from the global list any time the user added/edited a model in Settings, producing bugs like “Failed to switch model” for newly added models on Windows and Linux. See http_server.rb#api_switch_session_model and http_server.rb#api_save_config for the companion logic.



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/clacky/agent_config.rb', line 387

def deep_copy
  # dup gives us a new AgentConfig with independent scalar ivars but
  # the same @models reference — exactly what we want.
  copy = dup
  # But @virtual_model_overlay must be independent: a forked subagent
  # setting/clearing its own overlay must NOT leak into the parent.
  # (dup copies the ivar reference; an unset overlay is nil which is
  # already independent, but an active overlay must be cloned.)
  if @virtual_model_overlay
    copy.instance_variable_set(:@virtual_model_overlay, @virtual_model_overlay.dup)
  end
  if @session_model_overlay
    copy.instance_variable_set(:@session_model_overlay, @session_model_overlay.dup)
  end
  copy
end

#default_modelObject

Get the default model (type: default) Falls back to current_model for backward compatibility



914
915
916
# File 'lib/clacky/agent_config.rb', line 914

def default_model
  find_model_by_type("default") || current_model
end

#derive_media_models!Object

Kept as a no-op for backward compatibility. Media auto entries are now derived virtually on read; nothing is materialized into @models.



694
695
696
# File 'lib/clacky/agent_config.rb', line 694

def derive_media_models!
  @models.reject! { |m| m["auto_injected"] && Clacky::Providers::MEDIA_KINDS.include?(m["type"].to_s) }
end

#effective_model_nameObject

The effective model name to use for API calls.

  • :primary_ok / nil → configured model_name (primary)

  • :fallback_active → fallback model

  • :probing → configured model_name (trying primary silently)



1058
1059
1060
1061
1062
1063
1064
1065
1066
# File 'lib/clacky/agent_config.rb', line 1058

def effective_model_name
  case @fallback_state
  when :fallback_active
    @fallback_model || model_name
  else
    # :primary_ok (nil) and :probing both use the primary model
    model_name
  end
end

#fallback_active?Boolean

Returns true when a fallback model is currently being used (:fallback_active or :probing states).

Returns:

  • (Boolean)


1045
1046
1047
# File 'lib/clacky/agent_config.rb', line 1045

def fallback_active?
  @fallback_state == :fallback_active || @fallback_state == :probing
end

#fallback_model_for(model_name) ⇒ String?

Look up the fallback model name for the given model name. Uses the provider preset’s fallback_models table. Returns nil if no fallback is configured for this model.

Parameters:

  • model_name (String)

    the primary model name (e.g. “abs-claude-sonnet-4-6”)

Returns:

  • (String, nil)


998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
# File 'lib/clacky/agent_config.rb', line 998

def fallback_model_for(model_name)
  m = current_model
  return nil unless m

  provider_id = Clacky::Providers.resolve_provider(
    base_url: m["base_url"],
    api_key:  m["api_key"]
  )
  return nil unless provider_id

  Clacky::Providers.fallback_model(provider_id, model_name)
end

#find_model_by_name_and_url(model_name, base_url = nil) ⇒ Hash?

Find model by composite key (model name + base_url). Used when restoring a session to match its original model without relying on the runtime-only id (which changes on every process restart). base_url is optional for backward compatibility with sessions saved before base_url was persisted.

Parameters:

  • model_name (String)

    the model’s “model” field (e.g. “dsk-deepseek-v4-pro”)

  • base_url (String, nil) (defaults to: nil)

    the model’s “base_url” field

Returns:

  • (Hash, nil)

    the matching model entry or nil



905
906
907
908
909
910
# File 'lib/clacky/agent_config.rb', line 905

def find_model_by_name_and_url(model_name, base_url = nil)
  @models.find do |m|
    m["model"] == model_name &&
      (base_url.nil? || m["base_url"] == base_url)
  end
end

#find_model_by_type(type) ⇒ Object

Find model by type (default or lite or media kind or ocr sidecar) Returns the model hash or nil if not found. For media kinds (image/video/audio): explicit user-configured (custom) entries win; otherwise an auto-derived virtual entry is returned based on the default model’s provider — mirroring how lite is virtually derived via #lite_model_config_for_current. For “ocr”: same custom→auto→nil pattern. Auto path first checks whether the default model itself supports vision (zero-overhead path, no sidecar needed); if not, derives from the provider’s default_ocr_model.



640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
# File 'lib/clacky/agent_config.rb', line 640

def find_model_by_type(type)
  kind = type.to_s
  if Clacky::Providers::MEDIA_KINDS.include?(kind)
    entry = @models.find { |m| m["type"] == kind }
    return nil if entry && entry["disabled"]
    if entry && entry["base_url"].to_s.strip != "" && entry["api_key"].to_s.strip != ""
      return entry
    end
    return derive_media_model(kind, model_override: entry && entry["model"])
  end
  if kind == "ocr"
    entry = @models.find { |m| m["type"] == "ocr" }
    return nil if entry && entry["disabled"]
    if entry && entry["base_url"].to_s.strip != "" && entry["api_key"].to_s.strip != ""
      return entry
    end
    return derive_ocr_model(model_override: entry && entry["model"])
  end
  @models.find { |m| m["type"] == type }
end

#get_model(index) ⇒ Object

Get model by index



464
465
466
# File 'lib/clacky/agent_config.rb', line 464

def get_model(index)
  @models[index]
end

#lite_modelObject

Explicit lite model entry (type: “lite”) — only present when the user configured ‘CLACKY_LITE_*` environment variables. Returns nil otherwise.

This is the “user override” path. The preferred way for subagents to obtain a lite model is ‘#lite_model_config_for_current`, which falls back to this method when an explicit lite exists.



924
925
926
# File 'lib/clacky/agent_config.rb', line 924

def lite_model
  find_model_by_type("lite")
end

#lite_model_config_for_currentHash?

Return a complete lite model config hash for the currently-active primary model, or nil if none is available.

Resolution order:

1. Explicit user-configured lite (type: "lite", from CLACKY_LITE_*
   env vars). Wins over provider presets so power users retain full
   control.
2. Provider preset: look up the current model's provider, consult its
   per-family `lite_models` table (e.g. openclacky: Claude → Haiku,
   DeepSeek V4-pro → DeepSeek V4-flash). If matched, return a virtual
   hash that reuses the current model's api_key / base_url — only
   the model name (and anthropic_format, if provider-specific) differ.
3. nil — either the provider has no lite mapping for this primary
   (e.g. the current model is already lite-class like Haiku), or the
   provider is unknown. Callers should treat this as "no lite
   available; use the primary as-is".

The returned hash is not added to @models. It’s consumed directly by ‘Agent#fork_subagent(model: “lite”)`, which applies the fields to the forked config. This means:

- Switching the primary model automatically changes which lite is
  used, with zero additional bookkeeping.
- @models stays a clean list of user-facing models (no phantom
  auto-injected entries cluttering the model picker in the UI).

Returns:

  • (Hash, nil)

    a hash with keys api_key, base_url, model, anthropic_format, plus an “id” of the form “lite:<primary_id>” for logging/debugging; nil if no lite is resolvable.



956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
# File 'lib/clacky/agent_config.rb', line 956

def lite_model_config_for_current
  # 1) Explicit user-configured lite wins
  explicit = find_model_by_type("lite")
  return explicit if explicit

  # 2) Provider preset derivation
  primary = current_model
  return nil unless primary && primary["base_url"] && primary["model"]

  # Use resolve_provider (base_url first, then clacky-* api_key fallback
  # for local-debug / self-hosted proxies).
  provider_id = Clacky::Providers.resolve_provider(
    base_url: primary["base_url"],
    api_key:  primary["api_key"]
  )
  return nil unless provider_id

  lite_name = Clacky::Providers.lite_model(provider_id, primary["model"])
  return nil unless lite_name

  # If the current primary IS already a lite-class model, skip.
  return nil if lite_name == primary["model"]

  {
    "id"               => "lite:#{primary["id"]}",
    "type"             => "lite",
    "api_key"          => primary["api_key"],
    "base_url"         => primary["base_url"],
    "model"            => lite_name,
    "anthropic_format" => primary["anthropic_format"] || false,
    "virtual"          => true  # marker: not a real @models entry
  }
end

#maybe_start_probingObject

Called at the start of every call_llm. If cooling-off has expired, transition from :fallback_active → :probing so the next request will silently test the primary model. No-op in any other state.



1024
1025
1026
1027
1028
1029
# File 'lib/clacky/agent_config.rb', line 1024

def maybe_start_probing
  return unless @fallback_state == :fallback_active
  return unless @fallback_since && (Time.now - @fallback_since) >= FALLBACK_COOLING_OFF_SECONDS

  @fallback_state = :probing
end

#media_state(kind) ⇒ Hash{String=>Object}

Returns the configured/derived media model entry for ‘kind`, plus a hint about its source. UI uses this to render the tri-state control.

Parameters:

  • kind (String)

    one of “image” / “video” / “audio”

Returns:

  • (Hash{String=>Object})

    keys: “configured” [Boolean] — anything available? “source” [String] — “off” | “auto” | “custom” “model” [String, nil] “base_url” [String, nil] “provider” [String, nil] — provider id “available” [Array<String>] — auto-source candidates from preset



756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
# File 'lib/clacky/agent_config.rb', line 756

def media_state(kind)
  kind = kind.to_s
  raw_entry = @models.find { |m| m["type"] == kind }

  if raw_entry && raw_entry["disabled"]
    default = find_model_by_type("default")
    default_provider = default && Clacky::Providers.resolve_provider(
      base_url: default["base_url"], api_key: default["api_key"]
    )
    available = default_provider ? Clacky::Providers.media_models(default_provider, kind) : []
    aliases  = default_provider ? Clacky::Providers.media_model_aliases(default_provider, kind) : {}
    return {
      "configured" => false,
      "source"     => "off",
      "model"      => nil,
      "base_url"   => nil,
      "provider"   => nil,
      "available"  => available,
      "aliases"    => aliases,
      "stale"      => false
    }
  end

  is_custom = raw_entry &&
              raw_entry["base_url"].to_s.strip != "" &&
              raw_entry["api_key"].to_s.strip != ""
  override_model = raw_entry && !is_custom ? raw_entry["model"] : nil

  entry = if is_custom
            raw_entry
          else
            derive_media_model(kind, model_override: override_model)
          end

  provider_id = if entry
                  Clacky::Providers.resolve_provider(
                    base_url: entry["base_url"], api_key: entry["api_key"]
                  )
                end

  available_provider_id = if is_custom
                            provider_id
                          else
                            default = find_model_by_type("default")
                            default && Clacky::Providers.resolve_provider(
                              base_url: default["base_url"], api_key: default["api_key"]
                            )
                          end
  available = available_provider_id ? Clacky::Providers.media_models(available_provider_id, kind) : []
  aliases   = available_provider_id ? Clacky::Providers.media_model_aliases(available_provider_id, kind) : {}

  stale = !!(override_model && entry && entry["model"] != override_model)

  {
    "configured" => !entry.nil?,
    "source"     => is_custom ? "custom" : (entry ? "auto" : "off"),
    "model"      => entry && entry["model"],
    "base_url"   => entry && entry["base_url"],
    "provider"   => provider_id,
    "available"  => available,
    "aliases"    => aliases,
    "stale"      => stale,
    "requested_model" => stale ? override_model : nil
  }
end

#model_nameObject

Get model name for current model



594
595
596
# File 'lib/clacky/agent_config.rb', line 594

def model_name
  current_model&.dig("model")
end

#model_name=(value) ⇒ Object

Set model name for current model (overlay-aware; see #api_key=).



599
600
601
602
603
604
605
606
# File 'lib/clacky/agent_config.rb', line 599

def model_name=(value)
  return unless resolve_current_model_entry
  if @virtual_model_overlay
    @virtual_model_overlay["model"] = value
  else
    resolve_current_model_entry["model"] = value
  end
end

#model_namesObject

List all model names



557
558
559
# File 'lib/clacky/agent_config.rb', line 557

def model_names
  @models.map { |m| m["model"] }
end

#models_configured?Boolean

Check if any model is configured

Returns:

  • (Boolean)


455
456
457
# File 'lib/clacky/agent_config.rb', line 455

def models_configured?
  !@models.empty? && !current_model.nil?
end

#ocr_stateHash{String=>Object}

Tri-state introspection for the OCR sidecar — mirrors #media_state shape so the Settings UI can reuse the same row component.

Returns:

  • (Hash{String=>Object})

    keys: “configured” — anything available (auto or custom) “source” — “off” | “auto” | “custom” “primary” — true when auto resolves to the default model itself

    (no sidecar call needed)
    

    “model”/“base_url”/“provider”/“available”



830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
# File 'lib/clacky/agent_config.rb', line 830

def ocr_state
  raw_entry = @models.find { |m| m["type"] == "ocr" }

  default = find_model_by_type("default")
  default_provider = default && Clacky::Providers.resolve_provider(
    base_url: default["base_url"], api_key: default["api_key"]
  )
  available = default_provider ? Clacky::Providers.ocr_models(default_provider) : []

  if raw_entry && raw_entry["disabled"]
    # A disabled OCR sidecar only means "no separate vision model"; it must
    # not override the fact that the chat model may handle images itself.
    anchor = current_model || default
    anchor_provider = anchor && Clacky::Providers.resolve_provider(
      base_url: anchor["base_url"], api_key: anchor["api_key"]
    )
    if anchor && anchor_provider &&
       Clacky::Providers.supports?(anchor_provider, :vision, model_name: anchor["model"])
      return {
        "configured" => true,
        "source"     => "primary",
        "model"      => anchor["model"],
        "base_url"   => anchor["base_url"],
        "provider"   => anchor_provider,
        "primary"    => true,
        "available"  => available
      }
    end
    return {
      "configured" => false,
      "source"     => "off",
      "model"      => nil,
      "base_url"   => nil,
      "provider"   => nil,
      "primary"    => false,
      "available"  => available
    }
  end

  is_custom = raw_entry &&
              raw_entry["base_url"].to_s.strip != "" &&
              raw_entry["api_key"].to_s.strip != ""
  override_model = raw_entry && !is_custom ? raw_entry["model"] : nil

  entry = if is_custom
            raw_entry
          else
            derive_ocr_model(model_override: override_model)
          end

  provider_id = if entry
                  Clacky::Providers.resolve_provider(
                    base_url: entry["base_url"], api_key: entry["api_key"]
                  )
                end

  {
    "configured" => !entry.nil?,
    "source"     => is_custom ? "custom" : (entry ? "auto" : "off"),
    "model"      => entry && entry["model"],
    "base_url"   => entry && entry["base_url"],
    "provider"   => provider_id,
    "primary"    => !!(entry && entry["primary"]),
    "available"  => available
  }
end

#probing?Boolean

Returns true only when we are silently probing the primary model.

Returns:

  • (Boolean)


1050
1051
1052
# File 'lib/clacky/agent_config.rb', line 1050

def probing?
  @fallback_state == :probing
end

#remove_model(index) ⇒ Object

Remove a model by index Returns true if removed, false if index out of range or it’s the last model



1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
# File 'lib/clacky/agent_config.rb', line 1229

def remove_model(index)
  # Don't allow removing the last model
  return false if @models.length <= 1
  return false if index < 0 || index >= @models.length

  removed = @models.delete_at(index)

  # Adjust current_model_index if necessary
  if @current_model_index >= @models.length
    @current_model_index = @models.length - 1
  end

  # If the removed model was the current one, clear @current_model_id.
  # current_model will then fall back to type: default / current_model_index.
  if removed && @current_model_id == removed["id"]
    @current_model_id = nil
  end

  true
end

#save(config_file = CONFIG_FILE) ⇒ Object



404
405
406
407
408
409
410
# File 'lib/clacky/agent_config.rb', line 404

def save(config_file = CONFIG_FILE)
  config_dir = File.dirname(config_file)
  FileUtils.mkdir_p(config_dir)
  File.write(config_file, to_yaml)
  FileUtils.chmod(0o600, config_file)
  Clacky::ProxyConfig.reset_cache! if defined?(Clacky::ProxyConfig)
end

#session_model_overlayHash?

Returns the active session sub-model overlay.

Returns:

  • (Hash, nil)

    the active session sub-model overlay



1164
1165
1166
# File 'lib/clacky/agent_config.rb', line 1164

def session_model_overlay
  @session_model_overlay
end

#session_model_overlay=(model_name) ⇒ Object

Apply a session-level sub-model override. Lives on this AgentConfig only (each session deep_copy’s its own scalar ivar) and survives a restart through the session file. Pass nil or “” to clear.

The override only rewrites the resolved current_model’s “model” field —api_key / base_url / anthropic_format come from the underlying card, so the user’s credentials and provider identity are untouched.

Parameters:

  • model_name (String, nil)

    sub-model name, e.g. “dsk-deepseek-v4-pro”



1155
1156
1157
1158
1159
1160
1161
# File 'lib/clacky/agent_config.rb', line 1155

def session_model_overlay=(model_name)
  if model_name.nil? || model_name.to_s.strip.empty?
    @session_model_overlay = nil
  else
    @session_model_overlay = { "model" => model_name.to_s.strip }
  end
end

#session_model_overlay_nameString?

Convenience accessor: the sub-model name currently pinned on this session, or nil when no override is active. Used by serializers and the WebUI to surface “card · sub-model” two-line displays.

Returns:

  • (String, nil)


1172
1173
1174
# File 'lib/clacky/agent_config.rb', line 1172

def session_model_overlay_name
  @session_model_overlay && @session_model_overlay["model"]
end

#set_default_model_by_id(id) ⇒ Boolean

Set the global default model marker (‘type: “default”`).

This is separate from ‘switch_model_by_id`:

- `switch_model_by_id` only changes this session's current model.
- `set_default_model_by_id` mutates the shared `@models` array by
  moving the `type: "default"` marker to the given model.

Use cases:

- CLI (single-session): when the user picks a model, we both switch
  this session AND update the global default so future CLI launches
  use the same model. Caller must `save` to persist.
- Web UI Settings save flow: also uses this (via payload).

Do NOT call from per-session model switching in multi-session contexts (Web UI session-level switch), since it would leak into other sessions and change what new sessions start with.

Only one model may carry ‘type: “default”` at a time — this method clears the marker on any other model that had it.

Note: if the target model currently has ‘type: “lite”`, this method will overwrite it with `“default”`. That matches the existing single-slot `type` field semantics in the codebase.

Parameters:

  • id (String)

    the model’s runtime id

Returns:

  • (Boolean)

    true if marker was moved, false if id not found



539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
# File 'lib/clacky/agent_config.rb', line 539

def set_default_model_by_id(id)
  return false if id.nil? || id.to_s.empty?

  target = @models.find { |m| m["id"] == id }
  return false if target.nil?

  # Clear existing default marker(s) — there should only be one, but
  # be defensive in case of corrupted config.
  @models.each do |m|
    next if m["id"] == id
    m.delete("type") if m["type"] == "default"
  end

  target["type"] = "default"
  true
end

#set_model_type(index, type) ⇒ Object

Set a model’s type (default, lite, image, video, or audio). At most one model carries each type at a time. Returns true if successful

Parameters:

  • index (Integer)

    the model index

  • type (String, nil)

    type tag, or nil to clear



1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
# File 'lib/clacky/agent_config.rb', line 1207

def set_model_type(index, type)
  return false if index < 0 || index >= @models.length
  return false unless ["default", "lite", "image", "video", "audio", "ocr", nil].include?(type)

  if type
    # Remove type from any other model that has it
    @models.each do |m|
      m.delete("type") if m["type"] == type
    end
    
    # Set type on target model
    @models[index]["type"] = type
  else
    # Remove type from target model
    @models[index].delete("type")
  end

  true
end

#switch_model_by_id(id) ⇒ Boolean

Switch the current session to a specific model, identified by its stable runtime id.

This is a per-session operation:

- Updates this AgentConfig's `@current_model_id` (primary truth)
- Updates `@current_model_index` for back-compat observers
- Does NOT mutate the shared `@models` array's `type: "default"`
  marker. The "default model" is a global setting (initial model
  for new sessions) and is only changed via the Settings UI
  "save config" flow (`api_save_config`).

Parameters:

  • id (String)

    the model’s runtime id (see parse_models)

Returns:

  • (Boolean)

    true if switched, false if id not found



481
482
483
484
485
486
487
488
489
490
491
492
493
494
# File 'lib/clacky/agent_config.rb', line 481

def switch_model_by_id(id)
  return false if id.nil? || id.to_s.empty?

  index = @models.find_index { |m| m["id"] == id }
  return false if index.nil?

  previous_id = @current_model_id
  @current_model_id = id
  @current_model_index = index

  @session_model_overlay = nil if previous_id != id

  true
end

#switch_model_by_name(name) ⇒ Boolean

Switch to a model by its display name (fuzzy match, case-insensitive).

Parameters:

  • name (String)

    the model name to search for (e.g. “gpt-5.3-codex”)

Returns:

  • (Boolean)

    true if switched, false if name not found



500
501
502
503
504
505
506
507
508
509
510
511
# File 'lib/clacky/agent_config.rb', line 500

def switch_model_by_name(name)
  return false if name.nil? || name.to_s.strip.empty?

  name_str = name.to_s.strip.downcase
  index = @models.find_index { |m| m["model"].to_s.downcase == name_str }
  return false if index.nil?

  @current_model_id = @models[index]["id"]
  @current_model_index = index

  true
end

#to_yamlObject

Serialize the current agent configuration to YAML. Outputs a hash with “settings” and “models” keys (new format). Backward compatibility: old flat-array format is still readable by .load.



434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# File 'lib/clacky/agent_config.rb', line 434

def to_yaml
  persistable_models = @models.reject { |m| m["auto_injected"] }.map do |m|
    m.reject { |k, _| RUNTIME_ONLY_FIELDS.include?(k) }
  end
  settings = {
    "enable_compression" => @enable_compression,
    "enable_prompt_caching" => @enable_prompt_caching,
    "compression_threshold" => @compression_threshold,
    "message_count_threshold" => @message_count_threshold,
    "memory_update_enabled" => @memory_update_enabled,
    "skill_evolution" => @skill_evolution,
    "max_running_agents" => @max_running_agents,
    "max_idle_agents" => @max_idle_agents,
    "default_working_dir" => @default_working_dir,
    "proxy_url" => @proxy_url,
    "media_output_dir" => @media_output_dir
  }
  YAML.dump("settings" => settings, "models" => persistable_models)
end

#virtual_model_overlayHash?

Returns the active overlay (read-only view; dup before mutating).

Returns:

  • (Hash, nil)

    the active overlay (read-only view; dup before mutating)



1142
1143
1144
# File 'lib/clacky/agent_config.rb', line 1142

def virtual_model_overlay
  @virtual_model_overlay
end