From d9519bb4aeed1d038770ee415b7780867ed42c94 Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Fri, 26 Jun 2026 12:31:50 +0530 Subject: [PATCH] feat(captain): add rubyllm provider config --- config/installation_config.yml | 18 ++++++ lib/llm/config.rb | 108 ++++++++++++++++++++++++++++----- lib/llm/models.rb | 18 +++++- spec/lib/llm/config_spec.rb | 36 +++++++++++ spec/lib/llm/models_spec.rb | 10 +++ 5 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 spec/lib/llm/config_spec.rb diff --git a/config/installation_config.yml b/config/installation_config.yml index 43fc6bb97..6a3b40f82 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -195,6 +195,24 @@ display_title: 'OpenAI API Endpoint (optional)' description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/' locked: false +- name: CAPTAIN_ANTHROPIC_API_KEY + display_title: 'Anthropic API Key' + description: 'The API key used to authenticate requests to Anthropic models for Captain AI.' + locked: false + type: secret +- name: CAPTAIN_ANTHROPIC_API_BASE + display_title: 'Anthropic API Base (optional)' + description: 'The Anthropic endpoint configured for use in Captain AI. Defaults to RubyLLM provider settings.' + locked: false +- name: CAPTAIN_GEMINI_API_KEY + display_title: 'Gemini API Key' + description: 'The API key used to authenticate requests to Gemini models for Captain AI.' + locked: false + type: secret +- name: CAPTAIN_GEMINI_API_BASE + display_title: 'Gemini API Base (optional)' + description: 'The Gemini endpoint configured for use in Captain AI. Defaults to RubyLLM provider settings.' + locked: false - name: CAPTAIN_EMBEDDING_MODEL display_title: 'Embedding Model (optional)' description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small' diff --git a/lib/llm/config.rb b/lib/llm/config.rb index 6528e90c8..59b001935 100644 --- a/lib/llm/config.rb +++ b/lib/llm/config.rb @@ -2,11 +2,25 @@ require 'ruby_llm' module Llm::Config DEFAULT_MODEL = 'gpt-4.1-mini'.freeze + DEFAULT_PROVIDER = 'openai'.freeze + + PROVIDER_CONFIGS = { + 'openai' => { + api_key: 'CAPTAIN_OPEN_AI_API_KEY', + api_base: 'CAPTAIN_OPEN_AI_ENDPOINT' + }, + 'anthropic' => { + api_key: 'CAPTAIN_ANTHROPIC_API_KEY', + api_base: 'CAPTAIN_ANTHROPIC_API_BASE' + }, + 'gemini' => { + api_key: 'CAPTAIN_GEMINI_API_KEY', + api_base: 'CAPTAIN_GEMINI_API_BASE' + } + }.freeze class << self - def initialized? - @initialized ||= false - end + def initialized? = @initialized ||= false def initialize! return if @initialized @@ -15,37 +29,101 @@ module Llm::Config @initialized = true end - def reset! - @initialized = false - end + def reset! = @initialized = false - def with_api_key(api_key, api_base: nil) + def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil) initialize! context = RubyLLM.context do |config| - config.openai_api_key = api_key - config.openai_api_base = api_base + configure_provider(config, provider: provider, api_key: api_key, api_base: api_base) end yield context end + def ruby_llm_provider_supported?(provider) + RubyLLM::Provider.providers.key?(provider.to_s.to_sym) + end + + def provider_options + PROVIDER_CONFIGS.keys.each_with_object({}) do |provider, result| + next unless ruby_llm_provider_supported?(provider) + + result[provider] = ruby_llm_provider_name(provider) + end + end + + def api_key_for(provider) + installation_config_value(provider, :api_key) + end + + def api_base_for(provider) + api_base = installation_config_value(provider, :api_base).presence + return if api_base.blank? + + normalized_api_base(provider, api_base) + end + + def provider_configured?(provider) + api_key_for(provider).present? + end + + def openai_provider?(provider) + provider.to_s == DEFAULT_PROVIDER + end + + def supports_tools_and_schema?(provider) + openai_provider?(provider) + end + + def configure_provider(config, provider:, api_key:, api_base: nil) + provider = provider.to_s + options = provider_configuration_options(provider) + api_key_option = :"#{provider}_api_key" + api_base_option = :"#{provider}_api_base" + + set_config_value(config, api_key_option, api_key) if api_key.present? && options.include?(api_key_option) + set_config_value(config, api_base_option, api_base) if api_base.present? && options.include?(api_base_option) + end + private def configure_ruby_llm RubyLLM.configure do |config| - config.openai_api_key = system_api_key if system_api_key.present? - config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present? + PROVIDER_CONFIGS.each_key do |provider| + next unless ruby_llm_provider_supported?(provider) + + configure_provider(config, provider: provider, api_key: api_key_for(provider), api_base: api_base_for(provider)) + end config.model_registry_file = Rails.root.join('config/llm_models.json').to_s config.logger = Rails.logger end end - def system_api_key - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value + def ruby_llm_provider_name(provider) + RubyLLM::Provider.providers[provider.to_s.to_sym].name end - def openai_endpoint - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value + def provider_configuration_options(provider) + RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_options || [] + end + + def set_config_value(config, option, value) + setter = :"#{option}=" + config.public_send(setter, value) if config.respond_to?(setter) + end + + def installation_config_value(provider, key) + config_name = PROVIDER_CONFIGS.dig(provider.to_s, key) + return if config_name.blank? + + InstallationConfig.find_by(name: config_name)&.value + end + + def normalized_api_base(provider, api_base) + endpoint = api_base.chomp('/').delete_suffix('/chat/completions') + return "#{endpoint}/v1" if openai_provider?(provider) && endpoint.exclude?('/v1') + + endpoint end end end diff --git a/lib/llm/models.rb b/lib/llm/models.rb index 896014262..18a09c6ca 100644 --- a/lib/llm/models.rb +++ b/lib/llm/models.rb @@ -12,11 +12,14 @@ module Llm::Models end def default_model_for(feature) - features.dig(feature.to_s, 'default') + default_model = features.dig(feature.to_s, 'default') + return default_model if supported_model?(default_model) + + models_for(feature).first end def models_for(feature) - features.dig(feature.to_s, 'models') || [] + (features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) } end def valid_model_for?(feature, model_name) @@ -31,6 +34,17 @@ module Llm::Models model_config(model_name)&.dig('provider') end + def supported_provider?(provider) + providers.key?(provider.to_s) && Llm::Config.ruby_llm_provider_supported?(provider) + end + + def supported_model?(model_name) + config = model_config(model_name) + return false unless config + + supported_provider?(config['provider']) + end + def feature_config(feature_key) feature = features[feature_key.to_s] return nil unless feature diff --git a/spec/lib/llm/config_spec.rb b/spec/lib/llm/config_spec.rb new file mode 100644 index 000000000..f602222a8 --- /dev/null +++ b/spec/lib/llm/config_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Llm::Config do + describe '.provider_options' do + it 'returns configured providers supported by RubyLLM' do + expect(described_class.provider_options).to include( + 'openai' => 'OpenAI', + 'anthropic' => 'Anthropic', + 'gemini' => 'Gemini' + ) + end + end + + describe '.api_base_for' do + it 'normalizes OpenAI-compatible endpoints to the v1 base' do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://proxy.example.com/chat/completions') + + expect(described_class.api_base_for('openai')).to eq('https://proxy.example.com/v1') + end + + it 'keeps non-OpenAI provider endpoints unchanged except trailing slashes' do + create(:installation_config, name: 'CAPTAIN_ANTHROPIC_API_BASE', value: 'https://anthropic.example.com/') + + expect(described_class.api_base_for('anthropic')).to eq('https://anthropic.example.com') + end + end + + describe '.supports_tools_and_schema?' do + it 'allows tool and schema configuration only for OpenAI' do + expect(described_class.supports_tools_and_schema?('openai')).to be true + expect(described_class.supports_tools_and_schema?('anthropic')).to be false + end + end +end diff --git a/spec/lib/llm/models_spec.rb b/spec/lib/llm/models_spec.rb index f93df20fb..5aa671141 100644 --- a/spec/lib/llm/models_spec.rb +++ b/spec/lib/llm/models_spec.rb @@ -40,6 +40,16 @@ RSpec.describe Llm::Models do end end + describe '.models_for' do + it 'filters out models whose provider is not supported by RubyLLM' do + allow(Llm::Config).to receive(:ruby_llm_provider_supported?) do |provider| + provider.to_s != 'anthropic' + end + + expect(described_class.models_for('assistant')).not_to include('claude-haiku-4.5') + end + end + describe '.feature_config' do it 'returns model metadata for a feature' do config = described_class.feature_config('editor')