Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9519bb4ae |
@@ -195,6 +195,24 @@
|
|||||||
display_title: 'OpenAI API Endpoint (optional)'
|
display_title: 'OpenAI API Endpoint (optional)'
|
||||||
description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/'
|
description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/'
|
||||||
locked: false
|
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
|
- name: CAPTAIN_EMBEDDING_MODEL
|
||||||
display_title: 'Embedding Model (optional)'
|
display_title: 'Embedding Model (optional)'
|
||||||
description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small'
|
description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small'
|
||||||
|
|||||||
+93
-15
@@ -2,11 +2,25 @@ require 'ruby_llm'
|
|||||||
|
|
||||||
module Llm::Config
|
module Llm::Config
|
||||||
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
|
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
|
class << self
|
||||||
def initialized?
|
def initialized? = @initialized ||= false
|
||||||
@initialized ||= false
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize!
|
def initialize!
|
||||||
return if @initialized
|
return if @initialized
|
||||||
@@ -15,37 +29,101 @@ module Llm::Config
|
|||||||
@initialized = true
|
@initialized = true
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset!
|
def reset! = @initialized = false
|
||||||
@initialized = false
|
|
||||||
end
|
|
||||||
|
|
||||||
def with_api_key(api_key, api_base: nil)
|
def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil)
|
||||||
initialize!
|
initialize!
|
||||||
context = RubyLLM.context do |config|
|
context = RubyLLM.context do |config|
|
||||||
config.openai_api_key = api_key
|
configure_provider(config, provider: provider, api_key: api_key, api_base: api_base)
|
||||||
config.openai_api_base = api_base
|
|
||||||
end
|
end
|
||||||
|
|
||||||
yield context
|
yield context
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def configure_ruby_llm
|
def configure_ruby_llm
|
||||||
RubyLLM.configure do |config|
|
RubyLLM.configure do |config|
|
||||||
config.openai_api_key = system_api_key if system_api_key.present?
|
PROVIDER_CONFIGS.each_key do |provider|
|
||||||
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
|
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.model_registry_file = Rails.root.join('config/llm_models.json').to_s
|
||||||
config.logger = Rails.logger
|
config.logger = Rails.logger
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def system_api_key
|
def ruby_llm_provider_name(provider)
|
||||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
RubyLLM::Provider.providers[provider.to_s.to_sym].name
|
||||||
end
|
end
|
||||||
|
|
||||||
def openai_endpoint
|
def provider_configuration_options(provider)
|
||||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+16
-2
@@ -12,11 +12,14 @@ module Llm::Models
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_model_for(feature)
|
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
|
end
|
||||||
|
|
||||||
def models_for(feature)
|
def models_for(feature)
|
||||||
features.dig(feature.to_s, 'models') || []
|
(features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_model_for?(feature, model_name)
|
def valid_model_for?(feature, model_name)
|
||||||
@@ -31,6 +34,17 @@ module Llm::Models
|
|||||||
model_config(model_name)&.dig('provider')
|
model_config(model_name)&.dig('provider')
|
||||||
end
|
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)
|
def feature_config(feature_key)
|
||||||
feature = features[feature_key.to_s]
|
feature = features[feature_key.to_s]
|
||||||
return nil unless feature
|
return nil unless feature
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -40,6 +40,16 @@ RSpec.describe Llm::Models do
|
|||||||
end
|
end
|
||||||
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
|
describe '.feature_config' do
|
||||||
it 'returns model metadata for a feature' do
|
it 'returns model metadata for a feature' do
|
||||||
config = described_class.feature_config('editor')
|
config = described_class.feature_config('editor')
|
||||||
|
|||||||
Reference in New Issue
Block a user