diff --git a/docs/HOST_APP_GUIDE.md b/docs/HOST_APP_GUIDE.md index a3f59845..19b3533a 100644 --- a/docs/HOST_APP_GUIDE.md +++ b/docs/HOST_APP_GUIDE.md @@ -195,7 +195,10 @@ CoPlan.configure do |config| # Required config.authenticate = ->(request) { ... } - # AI provider (optional) + # AI provider (optional) — powers plan summaries. + # Any endpoint speaking the OpenAI wire protocol works: Azure OpenAI, + # LiteLLM, vLLM, Ollama, an internal gateway. With no API key set, the + # features that need one are skipped rather than erroring. config.ai_base_url = "https://api.openai.com/v1" # default config.ai_api_key = nil config.ai_model = "gpt-4o" # default diff --git a/engine/app/services/coplan/ai_providers/open_ai.rb b/engine/app/services/coplan/ai_providers/open_ai.rb index 5b46d45c..39b2681e 100644 --- a/engine/app/services/coplan/ai_providers/open_ai.rb +++ b/engine/app/services/coplan/ai_providers/open_ai.rb @@ -1,18 +1,25 @@ module CoPlan module AiProviders class OpenAi - def self.call(system_prompt:, user_content:, model: "gpt-4o") + # Used when the host configures neither, so a host that sets only an + # API key still works. + DEFAULT_MODEL = "gpt-4o".freeze + DEFAULT_BASE_URL = "https://api.openai.com/v1".freeze + + def self.call(system_prompt:, user_content:, model: nil) new(system_prompt:, user_content:, model:).call end - def initialize(system_prompt:, user_content:, model:) + def initialize(system_prompt:, user_content:, model: nil) @system_prompt = system_prompt @user_content = user_content - @model = model + # An explicit argument wins, then the host's configuration. Callers + # pass a model only when the prompt needs a particular one. + @model = model.presence || CoPlan.configuration.ai_model.presence || DEFAULT_MODEL end def call - client = OpenAI::Client.new(access_token: api_key) + client = OpenAI::Client.new(access_token: api_key, uri_base: base_url) response = client.chat( parameters: { @@ -32,6 +39,13 @@ def call private + # Anything speaking the OpenAI wire protocol: Azure OpenAI, LiteLLM, + # vLLM, Ollama, an internal gateway. The gem appends its own "/v1" + # only when the base URL doesn't already carry one. + def base_url + CoPlan.configuration.ai_base_url.presence || DEFAULT_BASE_URL + end + def api_key key = CoPlan.configuration.ai_api_key || Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"] raise Error, "OpenAI API key not configured" if key.blank? diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 1cd491ef..c8db004b 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -1,6 +1,10 @@ module CoPlan class Configuration attr_accessor :authenticate, :api_authenticate, :sign_in_path + # AI provider, used for plan summaries. Any endpoint speaking the + # OpenAI wire protocol works — Azure OpenAI, LiteLLM, vLLM, Ollama, an + # internal gateway — by pointing `ai_base_url` at it. With no API key + # configured the features that need one degrade rather than raise. attr_accessor :ai_base_url, :ai_api_key, :ai_model attr_accessor :error_reporter attr_accessor :notification_handler diff --git a/spec/services/ai_providers/open_ai_spec.rb b/spec/services/ai_providers/open_ai_spec.rb index 777139fd..ddd3fed2 100644 --- a/spec/services/ai_providers/open_ai_spec.rb +++ b/spec/services/ai_providers/open_ai_spec.rb @@ -15,7 +15,7 @@ mock_client = instance_double(OpenAI::Client) allow(OpenAI::Client).to receive(:new).and_return(mock_client) allow(mock_client).to receive(:chat).and_return({ - "choices" => [{ "message" => { "content" => "Review feedback here." } }] + "choices" => [ { "message" => { "content" => "Review feedback here." } } ] }) result = described_class.call( @@ -36,6 +36,97 @@ ) end + # These two were configurable in name only: `ai_model` and + # `ai_base_url` were documented in the host guide, set in the host's + # initializer, and read by nothing. The model was hard-coded in this + # class's signature and the base URL never reached the client at all. + describe "host configuration" do + def stub_chat + mock_client = instance_double(OpenAI::Client) + allow(OpenAI::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:chat).and_return({ + "choices" => [ { "message" => { "content" => "ok" } } ] + }) + mock_client + end + + it "uses the model the host configured" do + allow(CoPlan.configuration).to receive(:ai_model).and_return("gpt-4o-mini") + mock_client = stub_chat + + described_class.call(system_prompt: system_prompt, user_content: user_content) + + expect(mock_client).to have_received(:chat) + .with(hash_including(parameters: hash_including(model: "gpt-4o-mini"))) + end + + # Callers that need a particular model for a particular prompt still + # get to say so. + it "lets an explicit model argument win" do + allow(CoPlan.configuration).to receive(:ai_model).and_return("gpt-4o-mini") + mock_client = stub_chat + + described_class.call(system_prompt: system_prompt, user_content: user_content, model: "o3") + + expect(mock_client).to have_received(:chat) + .with(hash_including(parameters: hash_including(model: "o3"))) + end + + it "falls back to a working default when the host clears the model" do + allow(CoPlan.configuration).to receive(:ai_model).and_return(nil) + mock_client = stub_chat + + described_class.call(system_prompt: system_prompt, user_content: user_content) + + expect(mock_client).to have_received(:chat) + .with(hash_including(parameters: hash_including(model: described_class::DEFAULT_MODEL))) + end + + # The point of the whole exercise: self-hosting against something + # that isn't OpenAI. + it "points the client at the host's base URL" do + allow(CoPlan.configuration).to receive(:ai_base_url).and_return("http://localhost:11434/v1") + stub_chat + + described_class.call(system_prompt: system_prompt, user_content: user_content) + + expect(OpenAI::Client).to have_received(:new) + .with(hash_including(uri_base: "http://localhost:11434/v1")) + end + + it "falls back to OpenAI when the host clears the base URL" do + allow(CoPlan.configuration).to receive(:ai_base_url).and_return(nil) + stub_chat + + described_class.call(system_prompt: system_prompt, user_content: user_content) + + expect(OpenAI::Client).to have_received(:new) + .with(hash_including(uri_base: described_class::DEFAULT_BASE_URL)) + end + + # Our default (and the documented custom URLs) end in /v1, and the + # gem appends its own api_version segment — but only when the base + # doesn't already carry one (ruby-openai 8.3.0, http.rb#uri). This + # exercises the real client's URI construction, no stubs, so a gem + # upgrade that changes that rule fails here instead of 404ing in + # production on api.openai.com/v1/v1/chat/completions. + it "does not let the client double up /v1 on the default base URL" do + client = OpenAI::Client.new(access_token: "test", uri_base: described_class::DEFAULT_BASE_URL) + + uri = client.send(:uri, path: "/chat/completions") + + expect(uri).to eq("https://api.openai.com/v1/chat/completions") + end + + it "still gains a /v1 when a custom base leaves it off" do + client = OpenAI::Client.new(access_token: "test", uri_base: "https://gateway.example.com/openai") + + uri = client.send(:uri, path: "/chat/completions") + + expect(uri).to eq("https://gateway.example.com/openai/v1/chat/completions") + end + end + it "raises an error when response has no content" do mock_client = instance_double(OpenAI::Client) allow(OpenAI::Client).to receive(:new).and_return(mock_client)