Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/HOST_APP_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions engine/app/services/coplan/ai_providers/open_ai.rb
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove /v1 from the ruby-openai URI base

With the bundled ruby-openai 8.3.0 client, uri_base is the server root and the client adds its configured API version (v1) when constructing the chat endpoint. Now that this value is actually passed into the client, the default becomes https://api.openai.com/v1/v1/chat/completions, so ordinary OpenAI summary requests return 404; the documented custom URLs ending in /v1 have the same problem. Use a root such as https://api.openai.com (and document custom roots accordingly), or explicitly account for the client's API-version handling.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Checked against the bundled gem (ruby-openai 8.3.0, pinned by sha in Gemfile.lock): uri skips appending api_version when the base already contains it — elsif @uri_base.include?(@api_version) in lib/openai/http.rb#uri. Verified against the real client, no stubs:

https://api.openai.com/v1              => https://api.openai.com/v1/chat/completions
https://api.openai.com                 => https://api.openai.com/v1/chat/completions
https://gateway.example.com/openai/v1  => https://gateway.example.com/openai/v1/chat/completions
https://gateway.example.com/openai     => https://gateway.example.com/openai/v1/chat/completions

No double /v1 in any configuration, including the documented custom URLs. The base-url spec in this PR was mock-based though, so the concern was fair that nothing pinned the gem's rule — 8da4147 adds two no-stub specs that exercise the real client's URI construction, so a future gem upgrade that changes the version-handling fails in CI instead of 404ing in production.


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: {
Expand All @@ -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?
Expand Down
4 changes: 4 additions & 0 deletions engine/lib/coplan/configuration.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
93 changes: 92 additions & 1 deletion spec/services/ai_providers/open_ai_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down
Loading