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.
Files changed (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,534 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octo
4
+ # Built-in model provider presets
5
+ # Provides default configurations for supported AI model providers
6
+ module Providers
7
+ # Provider preset definitions
8
+ # Each preset includes:
9
+ # - name: Human-readable provider name
10
+ # - base_url: Default API endpoint
11
+ # - api: API type (anthropic-messages, openai-responses, openai-completions)
12
+ # - default_model: Recommended default model
13
+ # - capabilities (optional): provider-level capability hash (e.g.
14
+ # { "vision" => false }). Applies to all models under this provider
15
+ # unless overridden by model_capabilities below.
16
+ # - model_capabilities (optional): per-model capability override map,
17
+ # { "<model_name>" => { "<cap>" => bool, ... } }. Use this when a
18
+ # single provider hosts models with different capabilities (e.g.
19
+ # octo hosts both vision-capable Claude and text-only DeepSeek).
20
+ # - model_api_overrides (optional): per-model API-type override map,
21
+ # { <Regexp|String> => "anthropic-messages" | "openai-completions" | ... }.
22
+ # Keys can be a plain model name or a Regexp matched against the model.
23
+ # The first key that matches wins; if none match, the provider's top-level
24
+ # "api" is used. Used so e.g. OpenRouter can keep "openai-responses" as
25
+ # its default while routing Claude models through the native Anthropic
26
+ # endpoint (which preserves cache_control fidelity).
27
+ PRESETS = {
28
+ "openrouter" => {
29
+ "name" => "OpenRouter",
30
+ "base_url" => "https://openrouter.ai/api/v1",
31
+ "api" => "openai-responses",
32
+ "default_model" => "anthropic/claude-sonnet-4-6",
33
+ # Curated default lineup. OpenRouter's full catalogue is enormous
34
+ # (hundreds of models) and the live /models endpoint isn't always
35
+ # reachable from every region — shipping a small list of the
36
+ # mainstream Claude + GPT entries gives users a working dropdown
37
+ # out of the box. Users can still type any other OpenRouter model
38
+ # ID manually; this list only seeds the picker.
39
+ "models" => [
40
+ "anthropic/claude-sonnet-4-6",
41
+ "anthropic/claude-opus-4-7",
42
+ "anthropic/claude-opus-4-6",
43
+ "anthropic/claude-haiku-4-5",
44
+ "openai/gpt-5.5",
45
+ "openai/gpt-5.4",
46
+ "openai/gpt-5.4-mini"
47
+ ],
48
+ # Per-primary lite pairing — Claude family pairs with Haiku, GPT
49
+ # family pairs with the mini variant. Mirrors the octo and
50
+ # openai presets above so subagents on OpenRouter get a sensible
51
+ # cheap/fast sidekick automatically.
52
+ "lite_models" => {
53
+ "anthropic/claude-sonnet-4-6" => "anthropic/claude-haiku-4-5",
54
+ "anthropic/claude-opus-4-7" => "anthropic/claude-haiku-4-5",
55
+ "anthropic/claude-opus-4-6" => "anthropic/claude-haiku-4-5",
56
+ "openai/gpt-5.5" => "openai/gpt-5.4-mini",
57
+ "openai/gpt-5.4" => "openai/gpt-5.4-mini"
58
+ },
59
+ # Fallback chain for degraded-endpoint scenarios. When the primary
60
+ # returns repeated 503/429 errors, the agent temporarily switches to
61
+ # the fallback model to keep sessions alive.
62
+ "fallback_models" => {
63
+ "anthropic/claude-sonnet-4-6" => "anthropic/claude-haiku-4-5",
64
+ "anthropic/claude-opus-4-7" => "anthropic/claude-sonnet-4-6",
65
+ "anthropic/claude-opus-4-6" => "anthropic/claude-sonnet-4-6",
66
+ "openai/gpt-5.5" => "openai/gpt-5.4-mini",
67
+ "openai/gpt-5.4" => "openai/gpt-5.4-mini"
68
+ },
69
+ # Per-model API type overrides. Matched by Regexp against the model name.
70
+ # Why this exists: OpenRouter proxies Claude via both its OpenAI-compatible
71
+ # /chat/completions endpoint AND a native Anthropic /v1/messages endpoint.
72
+ # The OpenAI shim is lossy for Claude's cache_control semantics — prefix
73
+ # rewrites inside the proxy cause ~10% prompt-cache misses. Pinning
74
+ # "anthropic/*" (and any direct "claude-*" alias) to the native Anthropic
75
+ # endpoint preserves cache_control byte-for-byte and matches what Claude
76
+ # Code CLI does internally. Non-Claude models (Gemini, GPT, etc.) keep
77
+ # the OpenAI shim — that's what OpenRouter documents as their primary.
78
+ "model_api_overrides" => {
79
+ /\Aanthropic\// => "anthropic-messages",
80
+ /\Aclaude[-.]/ => "anthropic-messages"
81
+ }.freeze,
82
+ "website_url" => "https://openrouter.ai/keys"
83
+ }.freeze,
84
+
85
+ "deepseekv4" => {
86
+ "name" => "DeepSeek V4",
87
+ # DeepSeek API is compatible with both OpenAI and Anthropic formats.
88
+ # We use the OpenAI-compatible endpoint here (matches kimi/minimax/glm style).
89
+ # For Anthropic-format usage, point base_url at https://api.deepseek.com/anthropic
90
+ # and change "api" to "anthropic-messages".
91
+ "base_url" => "https://api.deepseek.com",
92
+ "api" => "openai-completions",
93
+ "default_model" => "deepseek-v4-pro",
94
+ "lite_model" => "deepseek-v4-flash",
95
+ # Note: deepseek-chat and deepseek-reasoner are legacy aliases being
96
+ # deprecated on 2026-07-24; they map to deepseek-v4-flash's non-thinking
97
+ # and thinking modes respectively. Prefer deepseek-v4-flash / deepseek-v4-pro.
98
+ "models" => [
99
+ "deepseek-v4-flash",
100
+ "deepseek-v4-pro",
101
+ ],
102
+ # DeepSeek V4 API does not accept image inputs — text-only across all models.
103
+ "capabilities" => { "vision" => false }.freeze,
104
+ "website_url" => "https://platform.deepseek.com/api_keys"
105
+ }.freeze,
106
+
107
+ "minimax" => {
108
+ "name" => "Minimax",
109
+ "base_url" => "https://api.minimaxi.com/v1",
110
+ "api" => "openai-completions",
111
+ "default_model" => "MiniMax-M2.7",
112
+ "models" => ["MiniMax-M2.5", "MiniMax-M2.7"],
113
+ # MiniMax operates two regional endpoints with identical APIs & model
114
+ # lineup — mainland China (.com) and international (.io). Listing both
115
+ # lets find_by_base_url identify either one as provider "minimax",
116
+ # so capability checks (vision=false) fire correctly regardless of
117
+ # which endpoint the user configured.
118
+ "endpoint_variants" => [
119
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
120
+ { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
121
+ ].freeze,
122
+ # MiniMax M2.x does not support multimodal/vision input on this endpoint.
123
+ "capabilities" => { "vision" => false }.freeze,
124
+ "website_url" => "https://www.minimaxi.com/user-center/basic-information/interface-key"
125
+ }.freeze,
126
+
127
+ "kimi" => {
128
+ "name" => "Kimi (Moonshot)",
129
+ "base_url" => "https://api.moonshot.cn/v1",
130
+ "api" => "openai-completions",
131
+ "default_model" => "kimi-k2.6",
132
+ "models" => ["kimi-k2.6", "kimi-k2.5"],
133
+ # Moonshot operates two regional endpoints with identical APIs & model
134
+ # lineup — mainland China (.cn) and international (.ai). These are the
135
+ # pay-as-you-go Open Platform endpoints; the subscription-billed
136
+ # Coding Plan lives at api.kimi.com/coding with the unified
137
+ # `kimi-for-coding` model alias and is exposed as a separate
138
+ # top-level "kimi-coding" preset (different domain, distinct billing
139
+ # model, marketed by Moonshot as the standalone Kimi Code product).
140
+ # Listing both PAYG variants here lets find_by_base_url identify
141
+ # either one as provider "kimi", so downstream capability checks,
142
+ # fallback chains, and provider-specific behaviours work regardless
143
+ # of which endpoint the user configured.
144
+ "endpoint_variants" => [
145
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
146
+ { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
147
+ ].freeze,
148
+ # k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
149
+ "capabilities" => { "vision" => true }.freeze,
150
+ "website_url" => "https://platform.moonshot.cn/console/api-keys"
151
+ }.freeze,
152
+
153
+ "kimi-coding" => {
154
+ "name" => "Kimi Code (Coding Plan)",
155
+ # Subscription-billed Kimi Code endpoint — separate product from the
156
+ # PAYG Moonshot Open Platform (api.moonshot.cn/v1 / .ai/v1). Uses the
157
+ # unified `kimi-for-coding` model alias which the Coding Plan backend
158
+ # routes to the appropriate K2 variant (Kimi-k2.6 today; 262K context,
159
+ # 32K max output, supports vision/video/reasoning).
160
+ #
161
+ # Why anthropic-messages: Moonshot exposes the Coding Plan via two
162
+ # URLs on the same domain — an Anthropic-format endpoint at
163
+ # api.kimi.com/coding/ (used by Claude Code via ANTHROPIC_BASE_URL)
164
+ # and an OpenAI-compatible endpoint at api.kimi.com/coding/v1 (used
165
+ # by Roo Code etc.). We route through anthropic-messages so
166
+ # cache_control fields round-trip byte-for-byte (the OpenAI shim is
167
+ # lossy for cache_control semantics — see OpenRouter preset above
168
+ # for the same reason). Verified against the live endpoint: response
169
+ # payload includes cache_creation_input_tokens / cache_read_input_tokens,
170
+ # so the cache layer is real on this backend.
171
+ #
172
+ # User-Agent gate: this endpoint enforces a UA-prefix whitelist
173
+ # limited to first-party coding agents (Kimi CLI, Claude Code, Roo
174
+ # Code, Kilo Code, ...). Requests carrying octo's default
175
+ # Faraday UA are rejected with HTTP 403 access_terminated_error.
176
+ # Client#anthropic_connection injects a Claude Code-shaped UA when
177
+ # @provider_id == "kimi-coding" — see the comment in client.rb for
178
+ # the policy rationale.
179
+ #
180
+ # Source: https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html
181
+ "base_url" => "https://api.kimi.com/coding",
182
+ "api" => "anthropic-messages",
183
+ "default_model" => "kimi-for-coding",
184
+ "models" => ["kimi-for-coding"],
185
+ # K2.6 backend behind the alias is multimodal (image + video input,
186
+ # reasoning). Same vision capability as the PAYG kimi preset.
187
+ "capabilities" => { "vision" => true }.freeze,
188
+ "website_url" => "https://www.kimi.com/code"
189
+ }.freeze,
190
+
191
+ "anthropic" => {
192
+ "name" => "Anthropic (Claude)",
193
+ "base_url" => "https://api.anthropic.com",
194
+ "api" => "anthropic-messages",
195
+ "default_model" => "claude-sonnet-4.6",
196
+ "models" => ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"],
197
+ "website_url" => "https://console.anthropic.com/settings/keys"
198
+ }.freeze,
199
+
200
+ "mimo" => {
201
+ "name" => "MiMo (Xiaomi)",
202
+ "base_url" => "https://api.xiaomimimo.com/v1",
203
+ "api" => "openai-completions",
204
+ "default_model" => "mimo-v2.5-pro",
205
+ "models" => ["mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni"],
206
+ # MiMo-V2-Pro is text-only; MiMo-V2-Omni supports vision (omni = multimodal).
207
+ "capabilities" => { "vision" => false }.freeze,
208
+ "model_capabilities" => {
209
+ "mimo-v2-omni" => { "vision" => true }.freeze
210
+ }.freeze,
211
+ "website_url" => "https://platform.xiaomimimo.com/"
212
+ }.freeze,
213
+
214
+ "glm" => {
215
+ "name" => "GLM (Z.ai / Zhipu)",
216
+ "base_url" => "https://open.bigmodel.cn/api/paas/v4",
217
+ "api" => "openai-completions",
218
+ "default_model" => "glm-5.1",
219
+ "models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
220
+ # Zhipu / Z.ai expose four functionally-equivalent endpoints:
221
+ # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
222
+ # each with a general-billing and a Coding-Plan subpath. They share the
223
+ # same model lineup & identical capability profile, so a single preset
224
+ # with endpoint_variants is the right shape — one source of truth for
225
+ # vision/model_capabilities, four URLs recognised by find_by_base_url.
226
+ # Without this, users pointing at api.z.ai or the /coding/ path fell
227
+ # through to the conservative "assume vision=true" default and got
228
+ # hallucinated image descriptions on text-only GLM models (C-5563).
229
+ "endpoint_variants" => [
230
+ { "label" => "Mainland · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mainland_cn_payg", "base_url" => "https://open.bigmodel.cn/api/paas/v4", "region" => "cn" }.freeze,
231
+ { "label" => "Mainland · Coding Plan", "label_key" => "settings.models.baseurl.variant.mainland_cn_coding", "base_url" => "https://open.bigmodel.cn/api/coding/paas/v4", "region" => "cn" }.freeze,
232
+ { "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg", "base_url" => "https://api.z.ai/api/paas/v4", "region" => "intl" }.freeze,
233
+ { "label" => "International · Coding Plan", "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4", "region" => "intl" }.freeze
234
+ ].freeze,
235
+ # GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
236
+ "capabilities" => { "vision" => false }.freeze,
237
+ "model_capabilities" => {
238
+ "glm-5v-turbo" => { "vision" => true }.freeze
239
+ }.freeze,
240
+ "website_url" => "https://open.bigmodel.cn/usercenter/apikeys"
241
+ }.freeze,
242
+
243
+ "openai" => {
244
+ "name" => "OpenAI (GPT)",
245
+ "base_url" => "https://api.openai.com/v1",
246
+ "api" => "openai-completions",
247
+ "default_model" => "gpt-5.5",
248
+ "models" => [
249
+ "gpt-5.5",
250
+ "gpt-5.4",
251
+ "gpt-5.4-mini",
252
+ "gpt-5.4-nano",
253
+ "o4-mini",
254
+ "o3"
255
+ ],
256
+ # GPT-5.x and o-series models are multimodal (text + image input).
257
+ "capabilities" => { "vision" => true }.freeze,
258
+ # Per-primary lite pairing: subagents use mini/nano for cheap/fast work.
259
+ # o4-mini and o3 are reasoning models without a lite-tier sibling here.
260
+ "lite_models" => {
261
+ "gpt-5.5" => "gpt-5.4-mini",
262
+ "gpt-5.4" => "gpt-5.4-mini"
263
+ },
264
+ "website_url" => "https://platform.openai.com/api-keys"
265
+ }.freeze,
266
+
267
+ "qwen" => {
268
+ "name" => "Qwen (Alibaba)",
269
+ "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
270
+ "api" => "openai-completions",
271
+ "default_model" => "qwen3.6-plus",
272
+ "models" => [
273
+ "qwen3.6-plus",
274
+ "qwen3.6-max",
275
+ "qwen3.6-27b",
276
+ "qwen3.6-flash",
277
+ "qwen-plus-latest",
278
+ "qwen-vl-plus",
279
+ "qwen-vl-max"
280
+ ],
281
+ "endpoint_variants" => [
282
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1", "region" => "cn" }.freeze,
283
+ { "label" => "Singapore", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
284
+ { "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
285
+ ].freeze,
286
+ "capabilities" => { "vision" => false }.freeze,
287
+ "model_capabilities" => {
288
+ "qwen3.6-27b" => { "vision" => true }.freeze,
289
+ "qwen-vl-plus" => { "vision" => true }.freeze,
290
+ "qwen-vl-max" => { "vision" => true }.freeze
291
+ }.freeze,
292
+ "lite_models" => {
293
+ "qwen3.6-plus" => "qwen3.6-flash",
294
+ "qwen3.6-max" => "qwen3.6-flash",
295
+ "qwen3.6-27b" => "qwen3.6-flash",
296
+ "qwen-plus-latest" => "qwen3.6-flash"
297
+ },
298
+ "website_url" => "https://bailian.console.aliyun.com/?apiKey=1"
299
+ }.freeze
300
+
301
+ }.freeze
302
+
303
+ class << self
304
+ # Check if a provider preset exists
305
+ # @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
306
+ # @return [Boolean] True if the preset exists
307
+ def exists?(provider_id)
308
+ PRESETS.key?(provider_id)
309
+ end
310
+
311
+ # Get a provider preset by ID
312
+ # @param provider_id [String] The provider identifier
313
+ # @return [Hash, nil] The preset configuration or nil if not found
314
+ def get(provider_id)
315
+ PRESETS[provider_id]
316
+ end
317
+
318
+ # Get the default model for a provider
319
+ # @param provider_id [String] The provider identifier
320
+ # @return [String, nil] The default model name or nil if provider not found
321
+ def default_model(provider_id)
322
+ preset = PRESETS[provider_id]
323
+ preset&.dig("default_model")
324
+ end
325
+
326
+ # Get the base URL for a provider
327
+ # @param provider_id [String] The provider identifier
328
+ # @return [String, nil] The base URL or nil if provider not found
329
+ def base_url(provider_id)
330
+ preset = PRESETS[provider_id]
331
+ preset&.dig("base_url")
332
+ end
333
+
334
+ # Get the API type for a provider
335
+ # @param provider_id [String] The provider identifier
336
+ # @return [String, nil] The API type or nil if provider not found
337
+ def api_type(provider_id)
338
+ preset = PRESETS[provider_id]
339
+ preset&.dig("api")
340
+ end
341
+
342
+ # Resolve the API type for a specific provider+model pair.
343
+ #
344
+ # Resolution order:
345
+ # 1. PRESETS[provider_id]["model_api_overrides"] — first key (String or
346
+ # Regexp) that matches the model name wins.
347
+ # 2. PRESETS[provider_id]["api"] — the provider-wide default.
348
+ # 3. nil — unknown provider.
349
+ #
350
+ # Use this instead of api_type when you need the precise transport for a
351
+ # given model (e.g. routing OpenRouter's Claude requests to the native
352
+ # /v1/messages endpoint to preserve prompt-cache fidelity).
353
+ #
354
+ # @param provider_id [String] The provider identifier
355
+ # @param model_name [String, nil] The specific model name
356
+ # @return [String, nil] The API type (e.g. "anthropic-messages")
357
+ def api_type_for_model(provider_id, model_name)
358
+ preset = PRESETS[provider_id]
359
+ return nil unless preset
360
+
361
+ overrides = preset["model_api_overrides"]
362
+ if overrides.is_a?(Hash) && model_name
363
+ name = model_name.to_s
364
+ matched = overrides.find do |pattern, _api|
365
+ case pattern
366
+ when Regexp then pattern.match?(name)
367
+ when String then pattern == name
368
+ else false
369
+ end
370
+ end
371
+ return matched[1] if matched
372
+ end
373
+
374
+ preset["api"]
375
+ end
376
+
377
+ # Returns true when the provider+model should be talked to using the
378
+ # native Anthropic /v1/messages format. This is the single source of
379
+ # truth for deciding anthropic_format at Client construction time.
380
+ # @param provider_id [String] The provider identifier
381
+ # @param model_name [String, nil] The specific model name
382
+ # @return [Boolean]
383
+ def anthropic_format_for_model?(provider_id, model_name)
384
+ api_type_for_model(provider_id, model_name) == "anthropic-messages"
385
+ end
386
+
387
+ # List all available provider IDs
388
+ # @return [Array<String>] List of provider identifiers
389
+ def provider_ids
390
+ PRESETS.keys
391
+ end
392
+
393
+ # List all available providers with their names
394
+ # @return [Array<Array(String, String)>] Array of [id, name] pairs
395
+ def list
396
+ PRESETS.map { |id, config| [id, config["name"]] }
397
+ end
398
+
399
+ # Get available models for a provider
400
+ # @param provider_id [String] The provider identifier
401
+ # @return [Array<String>] List of model names (empty if dynamic)
402
+ def models(provider_id)
403
+ preset = PRESETS[provider_id]
404
+ preset&.dig("models") || []
405
+ end
406
+
407
+ # Get the lite model for a provider.
408
+ # @param provider_id [String] The provider identifier
409
+ # @param primary_model [String, nil] The currently-selected primary model name.
410
+ # When given, look it up in the provider's `lite_models` table first
411
+ # (so one provider can host multiple model families, each with its own
412
+ # lite sidekick — e.g. Claude Opus/Sonnet → Haiku, DeepSeek Pro → Flash).
413
+ # Falls back to the global `lite_model` field for old-style presets
414
+ # (e.g. deepseekv4) that declare a single provider-wide lite.
415
+ # @return [String, nil] The lite model name, or nil when the primary is
416
+ # already lite-class (no entry) and no global `lite_model` is defined.
417
+ def lite_model(provider_id, primary_model = nil)
418
+ preset = PRESETS[provider_id]
419
+ return nil unless preset
420
+
421
+ if primary_model && preset["lite_models"].is_a?(Hash)
422
+ mapped = preset["lite_models"][primary_model]
423
+ return mapped if mapped
424
+ # When a `lite_models` table is defined but the current primary
425
+ # isn't listed, it means the primary is already a lite-class model
426
+ # (e.g. haiku / v4-flash) — do NOT fall back to the legacy single
427
+ # field, because that would incorrectly inject a lite for a model
428
+ # that doesn't need one.
429
+ return nil if preset["lite_models"].any?
430
+ end
431
+
432
+ preset["lite_model"]
433
+ end
434
+
435
+ # Get the fallback model for a given model within a provider.
436
+ # Returns nil if no fallback is defined for that model.
437
+ # @param provider_id [String] The provider identifier
438
+ # @param model [String] The primary model name
439
+ # @return [String, nil] The fallback model name or nil
440
+ def fallback_model(provider_id, model)
441
+ preset = PRESETS[provider_id]
442
+ preset&.dig("fallback_models", model)
443
+ end
444
+
445
+ # Find provider ID by base URL.
446
+ # Matches if the given URL starts with the provider's base_url (after normalisation),
447
+ # so both exact matches and sub-path variants (e.g. "/v1") are recognised.
448
+ #
449
+ # Also scans `endpoint_variants` (when present) so providers that operate
450
+ # multiple regional / billing-plan endpoints under the same identity
451
+ # (e.g. GLM on open.bigmodel.cn + api.z.ai, MiniMax on .com + .io) are
452
+ # all recognised as that single provider — one capability definition,
453
+ # N entry URLs. Without this, users configured with a non-default
454
+ # variant fall back to the "unknown provider" path and miss capability
455
+ # enforcement (see C-5563).
456
+ # @param base_url [String] The base URL to look up
457
+ # @return [String, nil] The provider ID or nil if not found
458
+ def find_by_base_url(base_url)
459
+ return nil if base_url.nil? || base_url.empty?
460
+ normalized = base_url.to_s.chomp("/")
461
+ PRESETS.find do |_id, preset|
462
+ # Collect every URL this preset claims: the canonical base_url plus
463
+ # any declared endpoint_variants. Dedup so the canonical one showing
464
+ # up in both lists doesn't change behaviour.
465
+ candidates = [preset["base_url"]]
466
+ variants = preset["endpoint_variants"]
467
+ if variants.is_a?(Array)
468
+ variants.each { |v| candidates << v["base_url"] if v.is_a?(Hash) }
469
+ end
470
+ candidates.compact.uniq.any? do |candidate|
471
+ preset_base = candidate.to_s.chomp("/")
472
+ next false if preset_base.empty?
473
+ normalized == preset_base || normalized.start_with?("#{preset_base}/")
474
+ end
475
+ end&.first
476
+ end
477
+
478
+ # Resolve the provider id for a model entry by base_url.
479
+ #
480
+ # @param base_url [String, nil] the configured base_url
481
+ # @param api_key [String, nil] unused, kept for API compatibility
482
+ # @return [String, nil] provider id or nil if unresolvable
483
+ def resolve_provider(base_url: nil, api_key: nil)
484
+ find_by_base_url(base_url)
485
+ end
486
+
487
+ # Resolve the capabilities hash for a given provider+model.
488
+ #
489
+ # Resolution order (most specific wins):
490
+ # 1. PRESETS[provider_id]["model_capabilities"][model_name] — per-model
491
+ # override, used when a single provider hosts a mix of capabilities
492
+ # (e.g. OpenRouter serves both Claude [vision] and DeepSeek [text]).
493
+ # 2. PRESETS[provider_id]["capabilities"] — provider-wide defaults,
494
+ # used when the whole lineup shares the same capabilities.
495
+ # 3. {} — no declaration; callers get the conservative default (true)
496
+ # via `supports?`.
497
+ #
498
+ # Returns a plain Hash (always safe to inspect; never nil).
499
+ # @param provider_id [String] The provider identifier
500
+ # @param model_name [String, nil] Optional specific model for override lookup
501
+ # @return [Hash] capabilities mapping (e.g. { "vision" => true })
502
+ def capabilities(provider_id, model_name: nil)
503
+ preset = PRESETS[provider_id]
504
+ return {} unless preset
505
+
506
+ provider_caps = preset["capabilities"] || {}
507
+ return provider_caps.dup unless model_name
508
+
509
+ model_caps = preset.dig("model_capabilities", model_name) || {}
510
+ provider_caps.merge(model_caps)
511
+ end
512
+
513
+ # Check if a provider+model supports a capability.
514
+ # Unknown provider / missing capability declaration → returns true
515
+ # (conservative default: assume supported unless we explicitly say otherwise).
516
+ # This keeps custom base_urls working and avoids over-aggressive downgrades.
517
+ #
518
+ # @param provider_id [String] The provider identifier
519
+ # @param capability [String, Symbol] The capability name (e.g. :vision, "vision")
520
+ # @param model_name [String, nil] Optional specific model name
521
+ # @return [Boolean] true unless the preset explicitly says false
522
+ def supports?(provider_id, capability, model_name: nil)
523
+ preset = PRESETS[provider_id]
524
+ return true unless preset
525
+
526
+ key = capability.to_s
527
+ caps = capabilities(provider_id, model_name: model_name)
528
+ # When the capability is not declared at either level, default to true.
529
+ return true unless caps.key?(key)
530
+ caps[key] != false
531
+ end
532
+ end
533
+ end
534
+ end