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
2 changes: 0 additions & 2 deletions config/initializers/coplan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
}
}

config.ai_api_key = Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"]
config.ai_model = "gpt-4o"

# Optional: delegate user search to an external directory (e.g., People API).
# When unset, /api/v1/users/search queries the local coplan_users table.
Expand Down
26 changes: 20 additions & 6 deletions docs/HOST_APP_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ CoPlan.configure do |config|
}
}

# Optional: AI provider configuration
config.ai_api_key = ENV["OPENAI_API_KEY"]
config.ai_model = "gpt-4o"
# Optional: AI provider (see "AI provider" section below). The engine
# ships with a built-in OpenAI plugin that auto-wires when an API key
# is present in Rails credentials (`openai.api_key`) or the env
# (`OPENAI_API_KEY`). Override `config.ai_call` to plug in a different
# backend.
end
```

Expand Down Expand Up @@ -137,9 +139,21 @@ CoPlan.configure do |config|
config.authenticate = ->(request) { ... }

# AI provider (optional)
config.ai_base_url = "https://api.openai.com/v1" # default
config.ai_api_key = nil
config.ai_model = "gpt-4o" # default
#
# The engine calls `config.ai_call` for every single-shot AI completion
# (e.g. plan summarization). The callable receives a `messages:` array
# of `{ role:, content: }` hashes (roles `:system`, `:user`,
# `:assistant`) and must return the assistant's text as a String.
#
# Defaults to a built-in OpenAI plugin that auto-resolves its key from
# Rails credentials (`openai.api_key`) or `ENV["OPENAI_API_KEY"]`, and
# picks the model from `ENV["OPENAI_MODEL"]` (default `gpt-4o`). Set
# to `nil` to disable AI features (jobs that need AI will discard
# cleanly). Override to plug in any other backend:
#
# config.ai_call = ->(messages:) {
# MyLlmGateway.call(messages: messages, model: "claude-3-5-sonnet")
# }

# Error reporting (optional)
config.error_reporter = ->(exception, context) {
Expand Down
4 changes: 2 additions & 2 deletions engine/app/jobs/coplan/summarize_plan_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ def generate_summary(plan)
return nil if content.blank?

CoPlan::Ai.call(
system_prompt: File.read(PROMPT_PATH),
user_content: content
system: File.read(PROMPT_PATH),
user: content
).to_s.strip.presence
end

Expand Down
63 changes: 53 additions & 10 deletions engine/app/services/coplan/ai.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,61 @@
module CoPlan
# Provider-agnostic facade for AI calls where the caller doesn't care
# which underlying provider runs the prompt. Use this from any place
# that just wants "an AI" (e.g. SummarizePlanJob).
# Provider-agnostic facade for single-shot AI completions. Delegates to
# `CoPlan.configuration.ai_call` — a callable the host wires up — so the
# engine never needs to know which model, provider, or LLM gateway is
# actually serving the request.
#
# The provider chosen here is an implementation detail; swap it without
# touching callers. Raises CoPlan::Ai::Error on provider failure so
# callers can `discard_on` without knowing which provider is in use.
# ## Usage
#
# # Convenience sugar (90% case):
# CoPlan::Ai.call(system: "You are concise.", user: "Hi")
#
# # General form (multi-turn, future-proof):
# CoPlan::Ai.call(messages: [
# { role: :system, content: "You are concise." },
# { role: :user, content: "Hi" },
# { role: :assistant, content: "Hello." },
# { role: :user, content: "Capital of France?" },
# ])
#
# The messages array is the canonical wire format every provider speaks
# (OpenAI chat completions, Anthropic messages, Gondola, Bedrock, etc.),
# so the host's `ai_call` lambda can pass it straight through with zero
# translation.
#
# ## Errors
#
# Any exception raised inside the configured `ai_call` is wrapped in
# CoPlan::Ai::Error, so callers can `discard_on CoPlan::Ai::Error`
# without coupling to the underlying provider.
#
# When a host has explicitly disabled AI by setting
# `config.ai_call = nil`, raises CoPlan::Ai::NoProviderError (a subclass
# of Error) so jobs still `discard_on` cleanly.
module Ai
class Error < StandardError; end
class NoProviderError < Error; end

def self.call(messages: nil, system: nil, user: nil)
messages ||= [
({ role: :system, content: system } if system),
({ role: :user, content: user } if user),
].compact

if messages.empty?
raise ArgumentError, "CoPlan::Ai.call requires `messages:`, or `system:` and/or `user:`"
end

callable = CoPlan.configuration.ai_call
unless callable
raise NoProviderError,
"No AI provider configured. Set CoPlan.configuration.ai_call in your host initializer."
end

def self.call(system_prompt:, user_content:)
AiProviders::OpenAi.call(system_prompt: system_prompt, user_content: user_content)
rescue AiProviders::OpenAi::Error => e
raise Error, e.message
begin
callable.call(messages: messages)
rescue => e
raise Error, e.message
end
end
end
end
28 changes: 18 additions & 10 deletions engine/app/services/coplan/ai_providers/open_ai.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
module CoPlan
module AiProviders
# Built-in OpenAI plugin for CoPlan.configuration.ai_call.
#
# Auto-wired by Configuration when ENV["OPENAI_API_KEY"] is set.
# Hosts that want a different backend (e.g. Square's Gondola gateway)
# should override `config.ai_call` in their initializer and ignore
# this class entirely.
#
# API key resolution order: Rails credentials (:openai → :api_key)
# then ENV["OPENAI_API_KEY"]. Model defaults to gpt-4o; override
# per-deployment via ENV["OPENAI_MODEL"].
class OpenAi
def self.call(system_prompt:, user_content:, model: "gpt-4o")
new(system_prompt:, user_content:, model:).call
DEFAULT_MODEL = "gpt-4o".freeze

def self.call(messages:, model: nil)
new(messages: messages, model: model || ENV.fetch("OPENAI_MODEL", DEFAULT_MODEL)).call
end

def initialize(system_prompt:, user_content:, model:)
@system_prompt = system_prompt
@user_content = user_content
def initialize(messages:, model:)
@messages = messages
@model = model
end

Expand All @@ -17,10 +28,7 @@ def call
response = client.chat(
parameters: {
model: @model,
messages: [
{ role: "system", content: @system_prompt },
{ role: "user", content: @user_content }
]
messages: @messages.map { |m| { role: m[:role].to_s, content: m[:content] } }
}
)

Expand All @@ -33,7 +41,7 @@ def call
private

def api_key
key = CoPlan.configuration.ai_api_key || Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"]
key = Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"]
raise Error, "OpenAI API key not configured" if key.blank?
key
end
Expand Down
40 changes: 36 additions & 4 deletions engine/lib/coplan/configuration.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
module CoPlan
class Configuration
attr_accessor :authenticate, :api_authenticate, :sign_in_path
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep documented AI setters from breaking host boot

The in-repo host guide still instructs hosts to set config.ai_api_key / config.ai_model and config.ai_base_url (docs/HOST_APP_GUIDE.md lines 66-68 and 140-142). After these accessors are removed, any host initializer following the current docs or upgrading before migrating to config.ai_call will raise NoMethodError during boot, before the app can start. Either keep deprecated setters as compatibility shims or update the documented migration path in this change.

Useful? React with 👍 / 👎.

attr_accessor :ai_base_url, :ai_api_key, :ai_model
attr_accessor :error_reporter
attr_accessor :notification_handler

Expand Down Expand Up @@ -70,14 +69,46 @@ class Configuration
# }
attr_accessor :user_search

# Pluggable AI surface. Invoked by CoPlan::Ai whenever the engine needs
# a single-shot completion.
#
# The callable receives a keyword arg `messages:` (Array of
# `{role:, content:}` hashes; roles are :system / :user / :assistant)
# and must return the assistant's text response as a String. Exceptions
# raised inside the callable are wrapped in CoPlan::Ai::Error so call
# sites can `discard_on` without knowing the underlying provider.
#
# Hosts wire whatever backend they want — a built-in OpenAI plugin
# (used by default; reads its key from Rails credentials
# `:openai/:api_key` or `ENV["OPENAI_API_KEY"]`), an internal LLM
# gateway like Gondola, an Anthropic client, a Bedrock client, a
# test stub, etc. Model choice and any deployment policy (project
# routing, rate limits, observability) live inside the callable —
# not in the engine's API surface.
#
# The default lambda is lazy: it dispatches to the OpenAI provider on
# call, and the provider raises CoPlan::Ai::Error at call time if no
# key is configured. AI-powered jobs (e.g. SummarizePlanJob) discard
# cleanly on that error.
#
# Example (host initializer):
# config.ai_call = ->(messages:) {
# GondolaProvider.call(messages: messages, model: "gpt-4o")
# }
attr_accessor :ai_call

def initialize
@authenticate = nil
@ai_base_url = "https://api.openai.com/v1"
@ai_api_key = nil
@ai_model = "gpt-4o"
@error_reporter = ->(exception, context) { Rails.error.report(exception, context: context) }
@notification_handler = nil
@track_event = nil
# Built-in OpenAI default. Always wired so credential-backed
# deployments work without any explicit boot-time check; the
# provider itself resolves the API key (Rails credentials → ENV)
# and raises CoPlan::Ai::Error at call time if nothing is set.
# Hosts can override this in their initializer to plug in a
# different backend.
@ai_call = ->(messages:) { CoPlan::AiProviders::OpenAi.call(messages: messages) }
@onboarding_banner = 'Want to upload Agentic plans? Give your agent <a href="/agent-instructions">these instructions</a>.'
@agent_curl_prefix = 'curl -s -H "Authorization: Bearer $TOKEN"'
@seed_plan_types = []
Expand Down Expand Up @@ -114,5 +145,6 @@ def show_api_tokens?
def web_push_configured?
vapid_public_key.present? && vapid_private_key.present? && vapid_subject.present?
end

end
end
4 changes: 2 additions & 2 deletions spec/jobs/summarize_plan_job_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
described_class.perform_now(plan_id: plan.id)

expect(CoPlan::Ai).to have_received(:call).with(
system_prompt: File.read(CoPlan::SummarizePlanJob::PROMPT_PATH),
user_content: plan.current_content
system: File.read(CoPlan::SummarizePlanJob::PROMPT_PATH),
user: plan.current_content
)
end

Expand Down
42 changes: 27 additions & 15 deletions spec/services/ai_providers/open_ai_spec.rb
Original file line number Diff line number Diff line change
@@ -1,58 +1,70 @@
require "rails_helper"

RSpec.describe CoPlan::AiProviders::OpenAi do
let(:model) { "gpt-4o" }
let(:system_prompt) { "You are a reviewer." }
let(:user_content) { "# My Plan\n\nSome content." }
let(:messages) do
[
{ role: :system, content: "You are a reviewer." },
{ role: :user, content: "# My Plan\n\nSome content." }
]
end

before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("OPENAI_API_KEY").and_return("test-key")
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with("OPENAI_MODEL", described_class::DEFAULT_MODEL).and_return("gpt-4o")
end

describe ".call" do
it "returns the AI response content" do
it "stringifies role symbols and forwards the messages array to the OpenAI client" do
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." } }]
})

result = described_class.call(
system_prompt: system_prompt,
user_content: user_content,
model: model
)
result = described_class.call(messages: messages)

expect(result).to eq("Review feedback here.")
expect(mock_client).to have_received(:chat).with(
parameters: {
model: model,
model: "gpt-4o",
messages: [
{ role: "system", content: system_prompt },
{ role: "user", content: user_content }
{ role: "system", content: "You are a reviewer." },
{ role: "user", content: "# My Plan\n\nSome content." }
]
}
)
end

it "honors an explicit `model:` override" do
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" } }]
})

described_class.call(messages: messages, model: "gpt-4o-mini")

expect(mock_client).to have_received(:chat).with(hash_including(parameters: hash_including(model: "gpt-4o-mini")))
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)
allow(mock_client).to receive(:chat).and_return({ "choices" => [] })

expect {
described_class.call(system_prompt: system_prompt, user_content: user_content, model: model)
described_class.call(messages: messages)
}.to raise_error(CoPlan::AiProviders::OpenAi::Error, "No response content from OpenAI")
end

it "raises an error when API key is not configured" do
allow(CoPlan.configuration).to receive(:ai_api_key).and_return(nil)
allow(ENV).to receive(:[]).with("OPENAI_API_KEY").and_return(nil)
allow(Rails.application.credentials).to receive(:dig).with(:openai, :api_key).and_return(nil)

expect {
described_class.call(system_prompt: system_prompt, user_content: user_content, model: model)
described_class.call(messages: messages)
}.to raise_error(CoPlan::AiProviders::OpenAi::Error, "OpenAI API key not configured")
end
end
Expand Down
Loading