octo-agent 0.11.2
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 +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- metadata +614 -0
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Octo
|
|
8
|
+
# ClaudeCode environment variable compatibility layer
|
|
9
|
+
# Provides configuration detection from ClaudeCode's environment variables
|
|
10
|
+
module ClaudeCodeEnv
|
|
11
|
+
# Environment variable names used by ClaudeCode
|
|
12
|
+
ENV_API_KEY = "ANTHROPIC_API_KEY"
|
|
13
|
+
ENV_AUTH_TOKEN = "ANTHROPIC_AUTH_TOKEN"
|
|
14
|
+
ENV_BASE_URL = "ANTHROPIC_BASE_URL"
|
|
15
|
+
|
|
16
|
+
# Default Anthropic API endpoint
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.anthropic.com"
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Check if any ClaudeCode authentication is configured
|
|
21
|
+
def configured?
|
|
22
|
+
!api_key.nil? && !api_key.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Get API key - prefer ANTHROPIC_API_KEY, fallback to ANTHROPIC_AUTH_TOKEN
|
|
26
|
+
def api_key
|
|
27
|
+
if ENV[ENV_API_KEY] && !ENV[ENV_API_KEY].empty?
|
|
28
|
+
ENV[ENV_API_KEY]
|
|
29
|
+
elsif ENV[ENV_AUTH_TOKEN] && !ENV[ENV_AUTH_TOKEN].empty?
|
|
30
|
+
ENV[ENV_AUTH_TOKEN]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get base URL from environment, or return default Anthropic API URL
|
|
35
|
+
def base_url
|
|
36
|
+
ENV[ENV_BASE_URL] && !ENV[ENV_BASE_URL].empty? ? ENV[ENV_BASE_URL] : DEFAULT_BASE_URL
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get configuration as a hash (includes configured values)
|
|
40
|
+
# Returns api_key and base_url (always available as there's a default)
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
"api_key" => api_key,
|
|
44
|
+
"base_url" => base_url
|
|
45
|
+
}.compact
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Octo environment variable layer
|
|
51
|
+
# Provides configuration from OCTO_XXX environment variables
|
|
52
|
+
module OctoEnv
|
|
53
|
+
# Environment variable names for default model
|
|
54
|
+
ENV_API_KEY = "OCTO_API_KEY"
|
|
55
|
+
ENV_BASE_URL = "OCTO_BASE_URL"
|
|
56
|
+
ENV_MODEL = "OCTO_MODEL"
|
|
57
|
+
ENV_ANTHROPIC_FORMAT = "OCTO_ANTHROPIC_FORMAT"
|
|
58
|
+
|
|
59
|
+
# Environment variable names for lite model
|
|
60
|
+
ENV_LITE_API_KEY = "OCTO_LITE_API_KEY"
|
|
61
|
+
ENV_LITE_BASE_URL = "OCTO_LITE_BASE_URL"
|
|
62
|
+
ENV_LITE_MODEL = "OCTO_LITE_MODEL"
|
|
63
|
+
ENV_LITE_ANTHROPIC_FORMAT = "OCTO_LITE_ANTHROPIC_FORMAT"
|
|
64
|
+
|
|
65
|
+
# Default model name (only for model, not base_url)
|
|
66
|
+
DEFAULT_MODEL = "claude-sonnet-4-5"
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# Check if default model is configured via environment variables
|
|
70
|
+
def default_configured?
|
|
71
|
+
!default_api_key.nil? && !default_api_key.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if lite model is configured via environment variables
|
|
75
|
+
def lite_configured?
|
|
76
|
+
!lite_api_key.nil? && !lite_api_key.empty?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get default model API key
|
|
80
|
+
def default_api_key
|
|
81
|
+
ENV[ENV_API_KEY] if ENV[ENV_API_KEY] && !ENV[ENV_API_KEY].empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get default model base URL (no default, must be explicitly set)
|
|
85
|
+
def default_base_url
|
|
86
|
+
ENV[ENV_BASE_URL] if ENV[ENV_BASE_URL] && !ENV[ENV_BASE_URL].empty?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get default model name
|
|
90
|
+
def default_model
|
|
91
|
+
ENV[ENV_MODEL] && !ENV[ENV_MODEL].empty? ? ENV[ENV_MODEL] : DEFAULT_MODEL
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get default model anthropic_format flag
|
|
95
|
+
def default_anthropic_format
|
|
96
|
+
return true if ENV[ENV_ANTHROPIC_FORMAT].nil? || ENV[ENV_ANTHROPIC_FORMAT].empty?
|
|
97
|
+
ENV[ENV_ANTHROPIC_FORMAT].downcase == "true"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get default model configuration as a hash
|
|
101
|
+
def default_model_config
|
|
102
|
+
{
|
|
103
|
+
"type" => "default",
|
|
104
|
+
"api_key" => default_api_key,
|
|
105
|
+
"base_url" => default_base_url,
|
|
106
|
+
"model" => default_model,
|
|
107
|
+
"anthropic_format" => default_anthropic_format
|
|
108
|
+
}.compact
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Get lite model API key
|
|
112
|
+
def lite_api_key
|
|
113
|
+
ENV[ENV_LITE_API_KEY] if ENV[ENV_LITE_API_KEY] && !ENV[ENV_LITE_API_KEY].empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get lite model base URL (no default, must be explicitly set)
|
|
117
|
+
def lite_base_url
|
|
118
|
+
ENV[ENV_LITE_BASE_URL] if ENV[ENV_LITE_BASE_URL] && !ENV[ENV_LITE_BASE_URL].empty?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Get lite model name
|
|
122
|
+
def lite_model
|
|
123
|
+
ENV[ENV_LITE_MODEL] && !ENV[ENV_LITE_MODEL].empty? ? ENV[ENV_LITE_MODEL] : "claude-haiku-4"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Get lite model anthropic_format flag
|
|
127
|
+
def lite_anthropic_format
|
|
128
|
+
return true if ENV[ENV_LITE_ANTHROPIC_FORMAT].nil? || ENV[ENV_LITE_ANTHROPIC_FORMAT].empty?
|
|
129
|
+
ENV[ENV_LITE_ANTHROPIC_FORMAT].downcase == "true"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get lite model configuration as a hash
|
|
133
|
+
def lite_model_config
|
|
134
|
+
{
|
|
135
|
+
"type" => "lite",
|
|
136
|
+
"api_key" => lite_api_key,
|
|
137
|
+
"base_url" => lite_base_url,
|
|
138
|
+
"model" => lite_model,
|
|
139
|
+
"anthropic_format" => lite_anthropic_format
|
|
140
|
+
}.compact
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class AgentConfig
|
|
146
|
+
CONFIG_DIR = File.join(Dir.home, ".octo")
|
|
147
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
|
148
|
+
|
|
149
|
+
# Default model for ClaudeCode environment
|
|
150
|
+
CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-5"
|
|
151
|
+
|
|
152
|
+
PERMISSION_MODES = [:auto_approve, :confirm_safes, :confirm_all].freeze
|
|
153
|
+
|
|
154
|
+
attr_accessor :permission_mode, :max_tokens, :verbose,
|
|
155
|
+
:enable_compression, :enable_prompt_caching,
|
|
156
|
+
:models, :current_model_index, :current_model_id,
|
|
157
|
+
:memory_update_enabled, :skill_evolution,
|
|
158
|
+
:next_message_suggestion_enabled,
|
|
159
|
+
:max_running_agents, :max_idle_agents,
|
|
160
|
+
:default_working_dir
|
|
161
|
+
|
|
162
|
+
def initialize(options = {})
|
|
163
|
+
@permission_mode = validate_permission_mode(options[:permission_mode])
|
|
164
|
+
@max_tokens = options[:max_tokens] || 16384
|
|
165
|
+
@verbose = options[:verbose] || false
|
|
166
|
+
@enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
|
|
167
|
+
# Enable prompt caching by default for cost savings
|
|
168
|
+
@enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]
|
|
169
|
+
|
|
170
|
+
# Models configuration
|
|
171
|
+
@models = options[:models] || []
|
|
172
|
+
# Ensure every model has a stable runtime id — this is the single
|
|
173
|
+
# invariant the rest of the system relies on. Regardless of how the
|
|
174
|
+
# config was built (load from yml, direct .new in tests, add_model,
|
|
175
|
+
# api_save_config), every model in @models will have an id.
|
|
176
|
+
@models.each { |m| m["id"] ||= SecureRandom.uuid }
|
|
177
|
+
|
|
178
|
+
@current_model_index = options[:current_model_index] || 0
|
|
179
|
+
# Stable runtime id for the currently-selected model. Preferred over
|
|
180
|
+
# @current_model_index because ids are immune to list reordering,
|
|
181
|
+
# additions, and edits to model fields. Ids are injected at load time
|
|
182
|
+
# and never persisted to config.yml (backward compatible with old files).
|
|
183
|
+
# If caller didn't specify current_model_id, prefer the model marked
|
|
184
|
+
# as `type: default` (the documented convention), falling back to
|
|
185
|
+
# models[current_model_index] only if no default marker exists.
|
|
186
|
+
@current_model_id = options[:current_model_id] ||
|
|
187
|
+
(@models.find { |m| m["type"] == "default" } || @models[@current_model_index])&.dig("id")
|
|
188
|
+
|
|
189
|
+
# Memory and skill evolution configuration
|
|
190
|
+
@memory_update_enabled = options[:memory_update_enabled].nil? ? true : options[:memory_update_enabled]
|
|
191
|
+
@next_message_suggestion_enabled = options[:next_message_suggestion_enabled].nil? ? true : options[:next_message_suggestion_enabled]
|
|
192
|
+
@skill_evolution = options[:skill_evolution] || {
|
|
193
|
+
enabled: true,
|
|
194
|
+
auto_create_threshold: 12,
|
|
195
|
+
reflection_mode: "llm_analysis"
|
|
196
|
+
}
|
|
197
|
+
# Deep-symbolize keys — YAML-loaded hashes come with string keys,
|
|
198
|
+
# but the rest of the codebase accesses with symbols.
|
|
199
|
+
@skill_evolution = @skill_evolution.transform_keys(&:to_sym)
|
|
200
|
+
@skill_evolution.transform_values! { |v| v.is_a?(Hash) ? v.transform_keys(&:to_sym) : v }
|
|
201
|
+
|
|
202
|
+
@max_running_agents = options[:max_running_agents] || 10
|
|
203
|
+
@max_idle_agents = options[:max_idle_agents] || 10
|
|
204
|
+
|
|
205
|
+
@default_working_dir = options[:default_working_dir] || ENV["OCTO_WORKSPACE_DIR"]
|
|
206
|
+
|
|
207
|
+
# Per-session virtual model overlay.
|
|
208
|
+
# When set, #current_model returns a *merged* hash (the resolved @models
|
|
209
|
+
# entry merged with this overlay) without mutating the shared @models
|
|
210
|
+
# array. Used by fork_subagent's virtual-lite path so a forked subagent
|
|
211
|
+
# can run on different credentials (e.g. Haiku instead of Opus) without
|
|
212
|
+
# polluting the parent agent's shared @models hashes.
|
|
213
|
+
# Keys honored: "api_key", "base_url", "model", "anthropic_format".
|
|
214
|
+
# @return [Hash, nil]
|
|
215
|
+
@virtual_model_overlay = options[:virtual_model_overlay]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Load configuration from file
|
|
219
|
+
def self.load(config_file = CONFIG_FILE)
|
|
220
|
+
# Load from config file first
|
|
221
|
+
if File.exist?(config_file)
|
|
222
|
+
data = YAML.load_file(config_file)
|
|
223
|
+
else
|
|
224
|
+
data = nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Extract settings from hash-format config (new format).
|
|
228
|
+
# Old flat-array configs have no settings section — all defaults.
|
|
229
|
+
loaded_settings = {}
|
|
230
|
+
if data.is_a?(Hash) && data["settings"].is_a?(Hash)
|
|
231
|
+
loaded_settings = data["settings"]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Parse models from config
|
|
235
|
+
models = parse_models(data)
|
|
236
|
+
|
|
237
|
+
# Priority: config file > OCTO_XXX env vars > ClaudeCode env vars
|
|
238
|
+
if models.empty?
|
|
239
|
+
# Try OCTO_XXX environment variables first
|
|
240
|
+
if OctoEnv.default_configured?
|
|
241
|
+
models << OctoEnv.default_model_config
|
|
242
|
+
# ClaudeCode (Anthropic) environment variable support is disabled
|
|
243
|
+
# elsif ClaudeCodeEnv.configured?
|
|
244
|
+
# models << {
|
|
245
|
+
# "type" => "default",
|
|
246
|
+
# "api_key" => ClaudeCodeEnv.api_key,
|
|
247
|
+
# "base_url" => ClaudeCodeEnv.base_url,
|
|
248
|
+
# "model" => CLAUDE_DEFAULT_MODEL,
|
|
249
|
+
# "anthropic_format" => true
|
|
250
|
+
# }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Add OCTO_LITE_XXX if configured (only when loading from env)
|
|
254
|
+
if OctoEnv.lite_configured?
|
|
255
|
+
models << OctoEnv.lite_model_config
|
|
256
|
+
end
|
|
257
|
+
else
|
|
258
|
+
# Config file exists, but check if we need to add env-based models
|
|
259
|
+
# Only add if no model with that type exists
|
|
260
|
+
has_default = models.any? { |m| m["type"] == "default" }
|
|
261
|
+
has_lite = models.any? { |m| m["type"] == "lite" }
|
|
262
|
+
|
|
263
|
+
# Add OCTO default if not in config and env is set
|
|
264
|
+
if !has_default && OctoEnv.default_configured?
|
|
265
|
+
models << OctoEnv.default_model_config
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Add OCTO lite if not in config and env is set
|
|
269
|
+
if !has_lite && OctoEnv.lite_configured?
|
|
270
|
+
models << OctoEnv.lite_model_config
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Ensure at least one model has type: default
|
|
274
|
+
# If no model has type: default, assign it to the first model
|
|
275
|
+
unless models.any? { |m| m["type"] == "default" }
|
|
276
|
+
models.first["type"] = "default" if models.any?
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Auto-inject lite model from provider preset is **no longer materialized
|
|
281
|
+
# into @models**. Lite is now a virtual, on-demand view derived from the
|
|
282
|
+
# currently-selected primary model — see `#lite_model_config_for_current`.
|
|
283
|
+
# This keeps @models a clean "list of user-facing models" and lets the
|
|
284
|
+
# lite companion track the current model at runtime, rather than being
|
|
285
|
+
# frozen at load time to whichever model happened to be the default.
|
|
286
|
+
#
|
|
287
|
+
# Legacy note: prior versions injected an entry here with
|
|
288
|
+
# `auto_injected: true`. That flag is still honored in to_yaml for
|
|
289
|
+
# safety (never persisted), but new injections never happen.
|
|
290
|
+
|
|
291
|
+
# Ensure every model has a stable runtime id — covers env-injected
|
|
292
|
+
# models (OCTO_XXX, CLAUDE_XXX) that don't go through parse_models.
|
|
293
|
+
# Ids are NOT persisted to config.yml (see to_yaml).
|
|
294
|
+
models.each { |m| m["id"] ||= SecureRandom.uuid }
|
|
295
|
+
|
|
296
|
+
# Find the index of the model marked as "default" (type: default)
|
|
297
|
+
# Fall back to 0 if no model has type: default
|
|
298
|
+
default_index = models.find_index { |m| m["type"] == "default" } || 0
|
|
299
|
+
default_id = models[default_index] && models[default_index]["id"]
|
|
300
|
+
|
|
301
|
+
# Build constructor args from loaded settings (new hash-format config)
|
|
302
|
+
# plus the parsed models. Only pass settings that have explicit values;
|
|
303
|
+
# omitted keys get their default from AgentConfig#initialize.
|
|
304
|
+
constructor_args = {
|
|
305
|
+
models: models,
|
|
306
|
+
current_model_index: default_index,
|
|
307
|
+
current_model_id: default_id
|
|
308
|
+
}
|
|
309
|
+
CONFIG_SETTINGS_KEYS.each do |key|
|
|
310
|
+
if loaded_settings.key?(key)
|
|
311
|
+
constructor_args[key.to_sym] = loaded_settings[key]
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
new(**constructor_args)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Auto-injection of provider-preset lite models into @models has been
|
|
319
|
+
# removed. Lite is now a virtual, on-demand role derived per-call from
|
|
320
|
+
# the currently-active primary model — see the instance method
|
|
321
|
+
# `#lite_model_config_for_current`. This class-level helper is kept as
|
|
322
|
+
# a no-op stub purely so older call sites (if any remain) don't blow up;
|
|
323
|
+
# it will be dropped in a future release.
|
|
324
|
+
private_class_method def self.inject_provider_lite_model(_models)
|
|
325
|
+
# no-op: lite is now a virtual view, not a materialized @models entry
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Create a per-session copy of this config.
|
|
329
|
+
#
|
|
330
|
+
# Plan B (shared models): we deliberately share the SAME @models array
|
|
331
|
+
# reference with all sessions (no deep clone). This is the key design
|
|
332
|
+
# decision that keeps session and global views in sync:
|
|
333
|
+
# - User adds a model in Settings → every live session sees it instantly.
|
|
334
|
+
# - User edits api_key/base_url → every live session's next API call
|
|
335
|
+
# picks up the new credentials (via current_model lookup).
|
|
336
|
+
# - Model ids are stable across edits, so each session's
|
|
337
|
+
# @current_model_id continues to resolve correctly.
|
|
338
|
+
#
|
|
339
|
+
# Per-session state that MUST stay isolated (permission_mode,
|
|
340
|
+
# @current_model_id, @current_model_index, fallback state) are scalar
|
|
341
|
+
# copies via `dup` and don't leak between sessions.
|
|
342
|
+
#
|
|
343
|
+
# Before Plan B, sessions held deep-copied @models — which silently
|
|
344
|
+
# diverged from the global list any time the user added/edited a model
|
|
345
|
+
# in Settings, producing bugs like "Failed to switch model" for newly
|
|
346
|
+
# added models on Windows and Linux. See http_server.rb#api_switch_session_model
|
|
347
|
+
# and http_server.rb#api_save_config for the companion logic.
|
|
348
|
+
def deep_copy
|
|
349
|
+
# dup gives us a new AgentConfig with independent scalar ivars but
|
|
350
|
+
# the same @models reference — exactly what we want.
|
|
351
|
+
copy = dup
|
|
352
|
+
# But @virtual_model_overlay must be independent: a forked subagent
|
|
353
|
+
# setting/clearing its own overlay must NOT leak into the parent.
|
|
354
|
+
# (dup copies the ivar reference; an unset overlay is nil which is
|
|
355
|
+
# already independent, but an active overlay must be cloned.)
|
|
356
|
+
if @virtual_model_overlay
|
|
357
|
+
copy.instance_variable_set(:@virtual_model_overlay, @virtual_model_overlay.dup)
|
|
358
|
+
end
|
|
359
|
+
copy
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def save(config_file = CONFIG_FILE)
|
|
363
|
+
config_dir = File.dirname(config_file)
|
|
364
|
+
FileUtils.mkdir_p(config_dir)
|
|
365
|
+
File.write(config_file, to_yaml)
|
|
366
|
+
FileUtils.chmod(0o600, config_file)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Convert to YAML format (top-level array)
|
|
370
|
+
# Auto-injected lite models (auto_injected: true) are excluded from persistence —
|
|
371
|
+
# they are regenerated at load time from the provider preset.
|
|
372
|
+
# Runtime-only fields (id, auto_injected) are stripped before writing so
|
|
373
|
+
# config.yml remains backward compatible with users on older versions.
|
|
374
|
+
RUNTIME_ONLY_FIELDS = %w[id auto_injected].freeze
|
|
375
|
+
|
|
376
|
+
# Settings keys that are persisted to config.yml.
|
|
377
|
+
# These map directly to AgentConfig accessors.
|
|
378
|
+
CONFIG_SETTINGS_KEYS = %w[
|
|
379
|
+
enable_compression enable_prompt_caching memory_update_enabled
|
|
380
|
+
next_message_suggestion_enabled
|
|
381
|
+
skill_evolution max_running_agents max_idle_agents
|
|
382
|
+
default_working_dir
|
|
383
|
+
].freeze
|
|
384
|
+
|
|
385
|
+
# Serialize the current agent configuration to YAML.
|
|
386
|
+
# Outputs a hash with "settings" and "models" keys (new format).
|
|
387
|
+
# Backward compatibility: old flat-array format is still readable by .load.
|
|
388
|
+
def to_yaml
|
|
389
|
+
persistable_models = @models.reject { |m| m["auto_injected"] }.map do |m|
|
|
390
|
+
m.reject { |k, _| RUNTIME_ONLY_FIELDS.include?(k) }
|
|
391
|
+
end
|
|
392
|
+
settings = {
|
|
393
|
+
"enable_compression" => @enable_compression,
|
|
394
|
+
"enable_prompt_caching" => @enable_prompt_caching,
|
|
395
|
+
"memory_update_enabled" => @memory_update_enabled,
|
|
396
|
+
"next_message_suggestion_enabled" => @next_message_suggestion_enabled,
|
|
397
|
+
"skill_evolution" => @skill_evolution,
|
|
398
|
+
"max_running_agents" => @max_running_agents,
|
|
399
|
+
"max_idle_agents" => @max_idle_agents,
|
|
400
|
+
"default_working_dir" => @default_working_dir
|
|
401
|
+
}
|
|
402
|
+
YAML.dump("settings" => settings, "models" => persistable_models)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Check if any model is configured
|
|
406
|
+
def models_configured?
|
|
407
|
+
!@models.empty? && !current_model.nil?
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# NOTE: current_model is defined below (near the id-aware lookup path)
|
|
411
|
+
# — the earlier duplicate definition was removed. Ruby silently picks the
|
|
412
|
+
# last definition, but keeping only one avoids confusion.
|
|
413
|
+
|
|
414
|
+
# Get model by index
|
|
415
|
+
def get_model(index)
|
|
416
|
+
@models[index]
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Switch the current session to a specific model, identified by its
|
|
420
|
+
# stable runtime id.
|
|
421
|
+
#
|
|
422
|
+
# This is a **per-session** operation:
|
|
423
|
+
# - Updates this AgentConfig's `@current_model_id` (primary truth)
|
|
424
|
+
# - Updates `@current_model_index` for back-compat observers
|
|
425
|
+
# - Does NOT mutate the shared `@models` array's `type: "default"`
|
|
426
|
+
# marker. The "default model" is a global setting (initial model
|
|
427
|
+
# for new sessions) and is only changed via the Settings UI
|
|
428
|
+
# "save config" flow (`api_save_config`).
|
|
429
|
+
#
|
|
430
|
+
# @param id [String] the model's runtime id (see parse_models)
|
|
431
|
+
# @return [Boolean] true if switched, false if id not found
|
|
432
|
+
def switch_model_by_id(id)
|
|
433
|
+
return false if id.nil? || id.to_s.empty?
|
|
434
|
+
|
|
435
|
+
index = @models.find_index { |m| m["id"] == id }
|
|
436
|
+
return false if index.nil?
|
|
437
|
+
|
|
438
|
+
@current_model_id = id
|
|
439
|
+
@current_model_index = index
|
|
440
|
+
|
|
441
|
+
true
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Switch to a model by its display name (fuzzy match, case-insensitive).
|
|
445
|
+
#
|
|
446
|
+
# @param name [String] the model name to search for (e.g. "gpt-5.3-codex")
|
|
447
|
+
# @return [Boolean] true if switched, false if name not found
|
|
448
|
+
def switch_model_by_name(name)
|
|
449
|
+
return false if name.nil? || name.to_s.strip.empty?
|
|
450
|
+
|
|
451
|
+
name_str = name.to_s.strip.downcase
|
|
452
|
+
index = @models.find_index { |m| m["model"].to_s.downcase == name_str }
|
|
453
|
+
return false if index.nil?
|
|
454
|
+
|
|
455
|
+
@current_model_id = @models[index]["id"]
|
|
456
|
+
@current_model_index = index
|
|
457
|
+
|
|
458
|
+
true
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Set the **global** default model marker (`type: "default"`).
|
|
462
|
+
#
|
|
463
|
+
# This is separate from `switch_model_by_id`:
|
|
464
|
+
# - `switch_model_by_id` only changes this session's current model.
|
|
465
|
+
# - `set_default_model_by_id` mutates the shared `@models` array by
|
|
466
|
+
# moving the `type: "default"` marker to the given model.
|
|
467
|
+
#
|
|
468
|
+
# Use cases:
|
|
469
|
+
# - CLI (single-session): when the user picks a model, we both switch
|
|
470
|
+
# this session AND update the global default so future CLI launches
|
|
471
|
+
# use the same model. Caller must `save` to persist.
|
|
472
|
+
# - Web UI Settings save flow: also uses this (via payload).
|
|
473
|
+
#
|
|
474
|
+
# Do NOT call from per-session model switching in multi-session contexts
|
|
475
|
+
# (Web UI session-level switch), since it would leak into other sessions
|
|
476
|
+
# and change what new sessions start with.
|
|
477
|
+
#
|
|
478
|
+
# Only one model may carry `type: "default"` at a time — this method
|
|
479
|
+
# clears the marker on any other model that had it.
|
|
480
|
+
#
|
|
481
|
+
# Note: if the target model currently has `type: "lite"`, this method
|
|
482
|
+
# will overwrite it with `"default"`. That matches the existing
|
|
483
|
+
# single-slot `type` field semantics in the codebase.
|
|
484
|
+
#
|
|
485
|
+
# @param id [String] the model's runtime id
|
|
486
|
+
# @return [Boolean] true if marker was moved, false if id not found
|
|
487
|
+
def set_default_model_by_id(id)
|
|
488
|
+
return false if id.nil? || id.to_s.empty?
|
|
489
|
+
|
|
490
|
+
target = @models.find { |m| m["id"] == id }
|
|
491
|
+
return false if target.nil?
|
|
492
|
+
|
|
493
|
+
# Clear existing default marker(s) — there should only be one, but
|
|
494
|
+
# be defensive in case of corrupted config.
|
|
495
|
+
@models.each do |m|
|
|
496
|
+
next if m["id"] == id
|
|
497
|
+
m.delete("type") if m["type"] == "default"
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
target["type"] = "default"
|
|
501
|
+
true
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# List all model names
|
|
505
|
+
def model_names
|
|
506
|
+
@models.map { |m| m["model"] }
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Get API key for current model
|
|
510
|
+
def api_key
|
|
511
|
+
current_model&.dig("api_key")
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Set API key for current model.
|
|
515
|
+
# When a virtual overlay is active, writes into the overlay (not the
|
|
516
|
+
# shared @models hash) to keep session-level isolation.
|
|
517
|
+
def api_key=(value)
|
|
518
|
+
return unless resolve_current_model_entry
|
|
519
|
+
if @virtual_model_overlay
|
|
520
|
+
@virtual_model_overlay["api_key"] = value
|
|
521
|
+
else
|
|
522
|
+
resolve_current_model_entry["api_key"] = value
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Get base URL for current model
|
|
527
|
+
def base_url
|
|
528
|
+
current_model&.dig("base_url")
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Set base URL for current model (overlay-aware; see #api_key=).
|
|
532
|
+
def base_url=(value)
|
|
533
|
+
return unless resolve_current_model_entry
|
|
534
|
+
if @virtual_model_overlay
|
|
535
|
+
@virtual_model_overlay["base_url"] = value
|
|
536
|
+
else
|
|
537
|
+
resolve_current_model_entry["base_url"] = value
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Get model name for current model
|
|
542
|
+
def model_name
|
|
543
|
+
current_model&.dig("model")
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Set model name for current model (overlay-aware; see #api_key=).
|
|
547
|
+
def model_name=(value)
|
|
548
|
+
return unless resolve_current_model_entry
|
|
549
|
+
if @virtual_model_overlay
|
|
550
|
+
@virtual_model_overlay["model"] = value
|
|
551
|
+
else
|
|
552
|
+
resolve_current_model_entry["model"] = value
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Check if should use Anthropic format for current model
|
|
557
|
+
def anthropic_format?
|
|
558
|
+
current_model&.dig("anthropic_format") || false
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Check if current model uses Bedrock Converse API (ABSK key prefix or abs- model prefix)
|
|
562
|
+
def bedrock?
|
|
563
|
+
Octo::MessageFormat::Bedrock.bedrock_api_key?(api_key.to_s, model_name.to_s)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Add a new model configuration
|
|
567
|
+
def add_model(model:, api_key:, base_url:, anthropic_format: false, type: nil)
|
|
568
|
+
@models << {
|
|
569
|
+
"id" => SecureRandom.uuid,
|
|
570
|
+
"api_key" => api_key,
|
|
571
|
+
"base_url" => base_url,
|
|
572
|
+
"model" => model,
|
|
573
|
+
"anthropic_format" => anthropic_format,
|
|
574
|
+
"type" => type
|
|
575
|
+
}.compact
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Find model by type (default or lite)
|
|
579
|
+
# Returns the model hash or nil if not found
|
|
580
|
+
def find_model_by_type(type)
|
|
581
|
+
@models.find { |m| m["type"] == type }
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Find model by composite key (model name + base_url).
|
|
585
|
+
# Used when restoring a session to match its original model without relying
|
|
586
|
+
# on the runtime-only id (which changes on every process restart).
|
|
587
|
+
# base_url is optional for backward compatibility with sessions saved
|
|
588
|
+
# before base_url was persisted.
|
|
589
|
+
# @param model_name [String] the model's "model" field (e.g. "dsk-deepseek-v4-pro")
|
|
590
|
+
# @param base_url [String, nil] the model's "base_url" field
|
|
591
|
+
# @return [Hash, nil] the matching model entry or nil
|
|
592
|
+
def find_model_by_name_and_url(model_name, base_url = nil)
|
|
593
|
+
@models.find do |m|
|
|
594
|
+
m["model"] == model_name &&
|
|
595
|
+
(base_url.nil? || m["base_url"] == base_url)
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Get the default model (type: default)
|
|
600
|
+
# Falls back to current_model for backward compatibility
|
|
601
|
+
def default_model
|
|
602
|
+
find_model_by_type("default") || current_model
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Explicit lite model entry (type: "lite") — only present when the user
|
|
606
|
+
# configured `OCTO_LITE_*` environment variables. Returns nil otherwise.
|
|
607
|
+
#
|
|
608
|
+
# This is the "user override" path. The preferred way for subagents to
|
|
609
|
+
# obtain a lite model is `#lite_model_config_for_current`, which falls
|
|
610
|
+
# back to this method when an explicit lite exists.
|
|
611
|
+
def lite_model
|
|
612
|
+
find_model_by_type("lite")
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Return a *complete* lite model config hash for the currently-active
|
|
616
|
+
# primary model, or nil if none is available.
|
|
617
|
+
#
|
|
618
|
+
# Resolution order:
|
|
619
|
+
# 1. Explicit user-configured lite (type: "lite", from OCTO_LITE_*
|
|
620
|
+
# env vars). Wins over provider presets so power users retain full
|
|
621
|
+
# control.
|
|
622
|
+
# 2. Provider preset: look up the current model's provider, consult its
|
|
623
|
+
# per-family `lite_models` table (e.g. octo: Claude → Haiku,
|
|
624
|
+
# DeepSeek V4-pro → DeepSeek V4-flash). If matched, return a virtual
|
|
625
|
+
# hash that reuses the current model's api_key / base_url — only
|
|
626
|
+
# the model name (and anthropic_format, if provider-specific) differ.
|
|
627
|
+
# 3. nil — either the provider has no lite mapping for this primary
|
|
628
|
+
# (e.g. the current model is already lite-class like Haiku), or the
|
|
629
|
+
# provider is unknown. Callers should treat this as "no lite
|
|
630
|
+
# available; use the primary as-is".
|
|
631
|
+
#
|
|
632
|
+
# The returned hash is **not** added to @models. It's consumed directly
|
|
633
|
+
# by `Agent#fork_subagent(model: "lite")`, which applies the fields to
|
|
634
|
+
# the forked config. This means:
|
|
635
|
+
# - Switching the primary model automatically changes which lite is
|
|
636
|
+
# used, with zero additional bookkeeping.
|
|
637
|
+
# - @models stays a clean list of user-facing models (no phantom
|
|
638
|
+
# auto-injected entries cluttering the model picker in the UI).
|
|
639
|
+
#
|
|
640
|
+
# @return [Hash, nil] a hash with keys api_key, base_url, model,
|
|
641
|
+
# anthropic_format, plus an "id" of the form "lite:<primary_id>" for
|
|
642
|
+
# logging/debugging; nil if no lite is resolvable.
|
|
643
|
+
def lite_model_config_for_current
|
|
644
|
+
# 1) Explicit user-configured lite wins
|
|
645
|
+
explicit = find_model_by_type("lite")
|
|
646
|
+
return explicit if explicit
|
|
647
|
+
|
|
648
|
+
# 2) Provider preset derivation
|
|
649
|
+
primary = current_model
|
|
650
|
+
return nil unless primary && primary["base_url"] && primary["model"]
|
|
651
|
+
|
|
652
|
+
# Use resolve_provider (base_url first, then octo-* api_key fallback
|
|
653
|
+
# for local-debug / self-hosted proxies).
|
|
654
|
+
provider_id = Octo::Providers.resolve_provider(
|
|
655
|
+
base_url: primary["base_url"],
|
|
656
|
+
api_key: primary["api_key"]
|
|
657
|
+
)
|
|
658
|
+
return nil unless provider_id
|
|
659
|
+
|
|
660
|
+
lite_name = Octo::Providers.lite_model(provider_id, primary["model"])
|
|
661
|
+
return nil unless lite_name
|
|
662
|
+
|
|
663
|
+
# If the current primary IS already a lite-class model, skip.
|
|
664
|
+
return nil if lite_name == primary["model"]
|
|
665
|
+
|
|
666
|
+
{
|
|
667
|
+
"id" => "lite:#{primary["id"]}",
|
|
668
|
+
"type" => "lite",
|
|
669
|
+
"api_key" => primary["api_key"],
|
|
670
|
+
"base_url" => primary["base_url"],
|
|
671
|
+
"model" => lite_name,
|
|
672
|
+
"anthropic_format" => primary["anthropic_format"] || false,
|
|
673
|
+
"virtual" => true # marker: not a real @models entry
|
|
674
|
+
}
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# How long to stay on the fallback model before probing the primary again.
|
|
678
|
+
FALLBACK_COOLING_OFF_SECONDS = 30 * 60 # 30 minutes
|
|
679
|
+
|
|
680
|
+
# Look up the fallback model name for the given model name.
|
|
681
|
+
# Uses the provider preset's fallback_models table.
|
|
682
|
+
# Returns nil if no fallback is configured for this model.
|
|
683
|
+
# @param model_name [String] the primary model name (e.g. "abs-claude-sonnet-4-6")
|
|
684
|
+
# @return [String, nil]
|
|
685
|
+
def fallback_model_for(model_name)
|
|
686
|
+
m = current_model
|
|
687
|
+
return nil unless m
|
|
688
|
+
|
|
689
|
+
provider_id = Octo::Providers.resolve_provider(
|
|
690
|
+
base_url: m["base_url"],
|
|
691
|
+
api_key: m["api_key"]
|
|
692
|
+
)
|
|
693
|
+
return nil unless provider_id
|
|
694
|
+
|
|
695
|
+
Octo::Providers.fallback_model(provider_id, model_name)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Switch to fallback model and start the cooling-off clock.
|
|
699
|
+
# Idempotent — calling again while already in :fallback_active renews the timestamp.
|
|
700
|
+
# @param fallback_model_name [String] the fallback model to use
|
|
701
|
+
def activate_fallback!(fallback_model_name)
|
|
702
|
+
@fallback_state = :fallback_active
|
|
703
|
+
@fallback_since = Time.now
|
|
704
|
+
@fallback_model = fallback_model_name
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# Called at the start of every call_llm.
|
|
708
|
+
# If cooling-off has expired, transition from :fallback_active → :probing
|
|
709
|
+
# so the next request will silently test the primary model.
|
|
710
|
+
# No-op in any other state.
|
|
711
|
+
def maybe_start_probing
|
|
712
|
+
return unless @fallback_state == :fallback_active
|
|
713
|
+
return unless @fallback_since && (Time.now - @fallback_since) >= FALLBACK_COOLING_OFF_SECONDS
|
|
714
|
+
|
|
715
|
+
@fallback_state = :probing
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Called when a successful API response is received.
|
|
719
|
+
# If we were :probing (testing primary after cooling-off), this confirms
|
|
720
|
+
# the primary model is healthy again and resets everything.
|
|
721
|
+
# No-op in :primary_ok or :fallback_active states.
|
|
722
|
+
def confirm_fallback_ok!
|
|
723
|
+
return unless @fallback_state == :probing
|
|
724
|
+
|
|
725
|
+
@fallback_state = nil
|
|
726
|
+
@fallback_since = nil
|
|
727
|
+
@fallback_model = nil
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# Returns true when a fallback model is currently being used
|
|
731
|
+
# (:fallback_active or :probing states).
|
|
732
|
+
def fallback_active?
|
|
733
|
+
@fallback_state == :fallback_active || @fallback_state == :probing
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# Returns true only when we are silently probing the primary model.
|
|
737
|
+
def probing?
|
|
738
|
+
@fallback_state == :probing
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# The effective model name to use for API calls.
|
|
742
|
+
# - :primary_ok / nil → configured model_name (primary)
|
|
743
|
+
# - :fallback_active → fallback model
|
|
744
|
+
# - :probing → configured model_name (trying primary silently)
|
|
745
|
+
def effective_model_name
|
|
746
|
+
case @fallback_state
|
|
747
|
+
when :fallback_active
|
|
748
|
+
@fallback_model || model_name
|
|
749
|
+
else
|
|
750
|
+
# :primary_ok (nil) and :probing both use the primary model
|
|
751
|
+
model_name
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
# Get current model configuration.
|
|
756
|
+
#
|
|
757
|
+
# Resolution order:
|
|
758
|
+
# 1. @current_model_id (primary source of truth — stable across list edits)
|
|
759
|
+
# 2. type: default (for config.yml that sets a default explicitly)
|
|
760
|
+
# 3. @current_model_index (back-compat for very old code paths)
|
|
761
|
+
def current_model
|
|
762
|
+
return nil if @models.empty?
|
|
763
|
+
|
|
764
|
+
resolved = resolve_current_model_entry
|
|
765
|
+
return nil unless resolved
|
|
766
|
+
|
|
767
|
+
# If a virtual overlay is active (e.g. subagent running on lite-model
|
|
768
|
+
# credentials), return a *merged copy* so callers see the overlay fields
|
|
769
|
+
# but the shared @models hash is never mutated.
|
|
770
|
+
if @virtual_model_overlay && !@virtual_model_overlay.empty?
|
|
771
|
+
resolved.merge(@virtual_model_overlay)
|
|
772
|
+
else
|
|
773
|
+
resolved
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Internal: resolve the current model entry from @models (no overlay).
|
|
778
|
+
# Extracted from the old #current_model so overlay logic sits in one place.
|
|
779
|
+
# @return [Hash, nil]
|
|
780
|
+
private def resolve_current_model_entry
|
|
781
|
+
if @current_model_id
|
|
782
|
+
m = @models.find { |mm| mm["id"] == @current_model_id }
|
|
783
|
+
return m if m
|
|
784
|
+
# id no longer exists (model was deleted). Fall through to other
|
|
785
|
+
# resolution strategies below, and clear the stale id.
|
|
786
|
+
@current_model_id = nil
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
default_model = find_model_by_type("default")
|
|
790
|
+
if default_model
|
|
791
|
+
# Opportunistically re-anchor to this default's id so subsequent
|
|
792
|
+
# lookups are O(1) and survive list reordering.
|
|
793
|
+
@current_model_id = default_model["id"]
|
|
794
|
+
return default_model
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Fallback to index-based for backward compatibility
|
|
798
|
+
m = @models[@current_model_index]
|
|
799
|
+
@current_model_id = m["id"] if m
|
|
800
|
+
m
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Apply a virtual model overlay for this session (and only this session).
|
|
804
|
+
# The overlay fields are merged on top of the current model entry when
|
|
805
|
+
# #current_model is called, without ever mutating the shared @models
|
|
806
|
+
# array or its hashes.
|
|
807
|
+
#
|
|
808
|
+
# Used by Agent#fork_subagent when routing a subagent through a virtual
|
|
809
|
+
# lite model (Haiku for Claude family, Flash for DeepSeek, ...). Apply on
|
|
810
|
+
# the forked config only — the parent config is untouched.
|
|
811
|
+
#
|
|
812
|
+
# @param overlay [Hash, nil] fields to overlay; pass nil or {} to clear.
|
|
813
|
+
# Recognized keys: "api_key", "base_url", "model", "anthropic_format".
|
|
814
|
+
# @return [void]
|
|
815
|
+
def apply_virtual_model_overlay!(overlay)
|
|
816
|
+
if overlay.nil? || overlay.empty?
|
|
817
|
+
@virtual_model_overlay = nil
|
|
818
|
+
else
|
|
819
|
+
# Dup so later mutations to the passed-in hash don't leak in.
|
|
820
|
+
@virtual_model_overlay = overlay.dup
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# @return [Hash, nil] the active overlay (read-only view; dup before mutating)
|
|
825
|
+
def virtual_model_overlay
|
|
826
|
+
@virtual_model_overlay
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# Query whether the *current* model supports a given capability.
|
|
830
|
+
#
|
|
831
|
+
# This is the single entry-point callers (Agent, downgrade pipeline, UI)
|
|
832
|
+
# should use instead of poking Providers directly. Benefits:
|
|
833
|
+
# - Always reflects the current model — switching with `/model` takes
|
|
834
|
+
# effect immediately, no caching, no stale warnings.
|
|
835
|
+
# - Handles the "custom base_url / unknown provider" case with a
|
|
836
|
+
# conservative default (assume supported), so self-hosted or new
|
|
837
|
+
# providers don't get accidentally downgraded.
|
|
838
|
+
#
|
|
839
|
+
# @param capability [String, Symbol] capability name (e.g. :vision)
|
|
840
|
+
# @return [Boolean] true if supported (or unknown); false only when the
|
|
841
|
+
# preset explicitly declares the capability as unsupported.
|
|
842
|
+
def current_model_supports?(capability)
|
|
843
|
+
m = current_model
|
|
844
|
+
# No model configured yet → nothing to judge; assume supported so we
|
|
845
|
+
# don't preemptively downgrade before a model is even picked.
|
|
846
|
+
return true unless m && m["base_url"]
|
|
847
|
+
|
|
848
|
+
provider_id = Octo::Providers.find_by_base_url(m["base_url"])
|
|
849
|
+
# Custom / self-hosted base_url not in our preset list → be conservative.
|
|
850
|
+
return true unless provider_id
|
|
851
|
+
|
|
852
|
+
Octo::Providers.supports?(provider_id, capability, model_name: m["model"])
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# Set a model's type (default or lite)
|
|
856
|
+
# Ensures only one model has each type
|
|
857
|
+
# @param index [Integer] the model index
|
|
858
|
+
# @param type [String, nil] "default", "lite", or nil to remove type
|
|
859
|
+
# Returns true if successful
|
|
860
|
+
def set_model_type(index, type)
|
|
861
|
+
return false if index < 0 || index >= @models.length
|
|
862
|
+
return false unless ["default", "lite", nil].include?(type)
|
|
863
|
+
|
|
864
|
+
if type
|
|
865
|
+
# Remove type from any other model that has it
|
|
866
|
+
@models.each do |m|
|
|
867
|
+
m.delete("type") if m["type"] == type
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# Set type on target model
|
|
871
|
+
@models[index]["type"] = type
|
|
872
|
+
else
|
|
873
|
+
# Remove type from target model
|
|
874
|
+
@models[index].delete("type")
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
true
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# Remove a model by index
|
|
881
|
+
# Returns true if removed, false if index out of range or it's the last model
|
|
882
|
+
def remove_model(index)
|
|
883
|
+
# Don't allow removing the last model
|
|
884
|
+
return false if @models.length <= 1
|
|
885
|
+
return false if index < 0 || index >= @models.length
|
|
886
|
+
|
|
887
|
+
removed = @models.delete_at(index)
|
|
888
|
+
|
|
889
|
+
# Adjust current_model_index if necessary
|
|
890
|
+
if @current_model_index >= @models.length
|
|
891
|
+
@current_model_index = @models.length - 1
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
# If the removed model was the current one, clear @current_model_id.
|
|
895
|
+
# current_model will then fall back to type: default / current_model_index.
|
|
896
|
+
if removed && @current_model_id == removed["id"]
|
|
897
|
+
@current_model_id = nil
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
true
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
private def validate_permission_mode(mode)
|
|
904
|
+
mode ||= :confirm_safes
|
|
905
|
+
mode = mode.to_sym
|
|
906
|
+
|
|
907
|
+
unless PERMISSION_MODES.include?(mode)
|
|
908
|
+
raise ArgumentError, "Invalid permission mode: #{mode}. Must be one of #{PERMISSION_MODES.join(', ')}"
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
mode
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# Parse models from config data
|
|
915
|
+
# Supports new top-level array format and old formats for backward compatibility
|
|
916
|
+
private_class_method def self.parse_models(data)
|
|
917
|
+
models = []
|
|
918
|
+
|
|
919
|
+
# Handle nil or empty data
|
|
920
|
+
return models if data.nil?
|
|
921
|
+
|
|
922
|
+
if data.is_a?(Array)
|
|
923
|
+
# New format: top-level array of model configurations
|
|
924
|
+
models = data.map do |m|
|
|
925
|
+
# Deep copy to avoid shared references between models
|
|
926
|
+
m = m.dup.transform_values { |v| v.is_a?(String) ? v.dup : v }
|
|
927
|
+
# Convert old name-based format to new model-based format if needed
|
|
928
|
+
if m["name"] && !m["model"]
|
|
929
|
+
m["model"] = m["name"]
|
|
930
|
+
m.delete("name")
|
|
931
|
+
end
|
|
932
|
+
m
|
|
933
|
+
end
|
|
934
|
+
elsif data.is_a?(Hash) && data["models"]
|
|
935
|
+
# Old format with "models:" key
|
|
936
|
+
if data["models"].is_a?(Array)
|
|
937
|
+
# Array under models key
|
|
938
|
+
models = data["models"].map do |m|
|
|
939
|
+
# Convert old name-based format to new model-based format
|
|
940
|
+
if m["name"] && !m["model"]
|
|
941
|
+
m["model"] = m["name"]
|
|
942
|
+
m.delete("name")
|
|
943
|
+
end
|
|
944
|
+
m
|
|
945
|
+
end
|
|
946
|
+
elsif data["models"].is_a?(Hash)
|
|
947
|
+
# Hash format with tier names as keys (very old format)
|
|
948
|
+
data["models"].each do |tier_name, config|
|
|
949
|
+
if config.is_a?(Hash)
|
|
950
|
+
model_config = {
|
|
951
|
+
"api_key" => config["api_key"],
|
|
952
|
+
"base_url" => config["base_url"],
|
|
953
|
+
"model" => config["model_name"] || config["model"] || tier_name,
|
|
954
|
+
"anthropic_format" => config["anthropic_format"] || false
|
|
955
|
+
}
|
|
956
|
+
models << model_config
|
|
957
|
+
elsif config.is_a?(String)
|
|
958
|
+
# Old-style tier with just model name
|
|
959
|
+
model_config = {
|
|
960
|
+
"api_key" => data["api_key"],
|
|
961
|
+
"base_url" => data["base_url"],
|
|
962
|
+
"model" => config,
|
|
963
|
+
"anthropic_format" => data["anthropic_format"] || false
|
|
964
|
+
}
|
|
965
|
+
models << model_config
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
end
|
|
969
|
+
elsif data.is_a?(Hash) && data["api_key"]
|
|
970
|
+
# Very old format: single model with global config
|
|
971
|
+
models << {
|
|
972
|
+
"api_key" => data["api_key"],
|
|
973
|
+
"base_url" => data["base_url"],
|
|
974
|
+
"model" => data["model"] || CLAUDE_DEFAULT_MODEL,
|
|
975
|
+
"anthropic_format" => data["anthropic_format"] || false
|
|
976
|
+
}
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
# Inject a runtime-only stable id for each model. Ids are NOT written
|
|
980
|
+
# back to config.yml (see `to_yaml`) so this is fully backward
|
|
981
|
+
# compatible — old yml files without ids just get fresh ids on load.
|
|
982
|
+
# The id is the source of truth for session→model identity and is
|
|
983
|
+
# immune to list reordering, additions, and field edits (api_key, etc).
|
|
984
|
+
models.each { |m| m["id"] ||= SecureRandom.uuid }
|
|
985
|
+
|
|
986
|
+
models
|
|
987
|
+
end
|
|
988
|
+
end
|
|
989
|
+
end
|