feat(captain): add rubyllm provider config

This commit is contained in:
aakashb95
2026-06-26 12:32:08 +05:30
parent d0b1c055e8
commit d9519bb4ae
5 changed files with 173 additions and 17 deletions
+18
View File
@@ -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'
+93 -15
View File
@@ -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
+16 -2
View File
@@ -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
+36
View File
@@ -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
+10
View File
@@ -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')