From edb377b4e27096b151228972e0dcb3adb93c844c Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Tue, 2 Jun 2026 16:14:22 -0500 Subject: [PATCH 1/2] Introduce config.ai_call: pluggable AI surface (messages-array) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the engine's hard-coded \`CoPlan::AiProviders::OpenAi.call\` plumbing with a host-supplied callable so deployments can swap in any backend (raw OpenAI, an internal LLM gateway like Square's Gondola, a test stub, etc.) without touching engine code. CoPlan::Ai.call(messages: [ { role: :system, content: "You are concise." }, { role: :user, content: "Hi" }, ]) # …with sugar for the 90% case: CoPlan::Ai.call(system: "You are concise.", user: "Hi") The messages-array shape is the canonical wire format every chat-style LLM API speaks (OpenAI chat completions, Anthropic Messages, Gondola, Bedrock), so the host's lambda passes it straight through with zero translation. # config/initializers/coplan.rb config.ai_call = ->(messages:) { GondolaProvider.call(messages: messages, model: "gpt-4o") } Model choice and deployment policy (which provider, project, rate limits, observability) live inside the callable β€” not on the engine's API surface. The engine never sees an API key or a model name. When \`ENV["OPENAI_API_KEY"]\` is present, Configuration auto-wires \`CoPlan::AiProviders::OpenAi\` so a standalone deployment works with zero config. The OpenAI plugin now accepts a messages array and honors \`ENV["OPENAI_MODEL"]\` (default gpt-4o) for per-deployment model tuning without code changes. - \`Configuration#ai_api_key\`, \`#ai_base_url\`, \`#ai_model\` accessors (deployment concerns now live inside the provider lambda) - \`coplan-square\`-style \`config.ai_api_key = ...\` lines in the host initializer (auto-wire handles it) - \`Configuration#ai_call\` (with docstring) + \`#ai_call_configured?\` predicate - \`CoPlan::Ai::NoProviderError\` (subclass of \`Ai::Error\`) for jobs that want to distinguish "not configured" from "call failed" Any exception raised inside the host's lambda is wrapped in \`CoPlan::Ai::Error\`, so existing call sites (\`SummarizePlanJob.discard_on CoPlan::Ai::Error\`) continue to work unchanged without coupling to the provider. Tests: 868 examples, 0 failures. Stacked on #121 (review-bot removal). πŸ€– Generated with [Amp](https://ampcode.com) Amp-Thread-ID: https://ampcode.com/threads/T-019e84ca-1bd1-7435-acbc-73b00095d896 Co-authored-by: Amp --- config/initializers/coplan.rb | 2 - engine/app/jobs/coplan/summarize_plan_job.rb | 4 +- engine/app/services/coplan/ai.rb | 63 ++++++++++++++++--- .../services/coplan/ai_providers/open_ai.rb | 28 ++++++--- engine/lib/coplan/configuration.rb | 40 ++++++++++-- spec/jobs/summarize_plan_job_spec.rb | 4 +- spec/services/ai_providers/open_ai_spec.rb | 42 ++++++++----- spec/services/ai_spec.rb | 59 +++++++++++++---- 8 files changed, 184 insertions(+), 58 deletions(-) diff --git a/config/initializers/coplan.rb b/config/initializers/coplan.rb index e717496..5d9c466 100644 --- a/config/initializers/coplan.rb +++ b/config/initializers/coplan.rb @@ -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. diff --git a/engine/app/jobs/coplan/summarize_plan_job.rb b/engine/app/jobs/coplan/summarize_plan_job.rb index f2d1c68..e4bbf0a 100644 --- a/engine/app/jobs/coplan/summarize_plan_job.rb +++ b/engine/app/jobs/coplan/summarize_plan_job.rb @@ -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 diff --git a/engine/app/services/coplan/ai.rb b/engine/app/services/coplan/ai.rb index a26ecf2..9c16416 100644 --- a/engine/app/services/coplan/ai.rb +++ b/engine/app/services/coplan/ai.rb @@ -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 no callable is configured, 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 " \ + "(or set OPENAI_API_KEY to auto-wire the built-in OpenAI plugin)." + 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 diff --git a/engine/app/services/coplan/ai_providers/open_ai.rb b/engine/app/services/coplan/ai_providers/open_ai.rb index 5b46d45..1c54b0e 100644 --- a/engine/app/services/coplan/ai_providers/open_ai.rb +++ b/engine/app/services/coplan/ai_providers/open_ai.rb @@ -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 @@ -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] } } } ) @@ -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 diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 7d94448..a3724ec 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -1,7 +1,6 @@ module CoPlan class Configuration attr_accessor :authenticate, :api_authenticate, :sign_in_path - attr_accessor :ai_base_url, :ai_api_key, :ai_model attr_accessor :error_reporter attr_accessor :notification_handler @@ -70,14 +69,43 @@ 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 + # (auto-wired below when ENV["OPENAI_API_KEY"] is present), 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. + # + # When nil, CoPlan::Ai raises CoPlan::Ai::NoProviderError on use and + # AI-powered jobs (e.g. SummarizePlanJob) discard cleanly. + # + # 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: auto-wired when an API key is available + # in the environment, so a standalone deployment gets working AI + # with zero config beyond setting OPENAI_API_KEY. Hosts can override + # this in their initializer to plug in a different backend. + @ai_call = if ENV["OPENAI_API_KEY"].present? + ->(messages:) { CoPlan::AiProviders::OpenAi.call(messages: messages) } + end @onboarding_banner = 'Want to upload Agentic plans? Give your agent these instructions.' @agent_curl_prefix = 'curl -s -H "Authorization: Bearer $TOKEN"' @seed_plan_types = [] @@ -114,5 +142,9 @@ def show_api_tokens? def web_push_configured? vapid_public_key.present? && vapid_private_key.present? && vapid_subject.present? end + + def ai_call_configured? + !ai_call.nil? + end end end diff --git a/spec/jobs/summarize_plan_job_spec.rb b/spec/jobs/summarize_plan_job_spec.rb index ae296e5..de26248 100644 --- a/spec/jobs/summarize_plan_job_spec.rb +++ b/spec/jobs/summarize_plan_job_spec.rb @@ -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 diff --git a/spec/services/ai_providers/open_ai_spec.rb b/spec/services/ai_providers/open_ai_spec.rb index 777139f..2c57f29 100644 --- a/spec/services/ai_providers/open_ai_spec.rb +++ b/spec/services/ai_providers/open_ai_spec.rb @@ -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 diff --git a/spec/services/ai_spec.rb b/spec/services/ai_spec.rb index 2d6b257..7f5a40d 100644 --- a/spec/services/ai_spec.rb +++ b/spec/services/ai_spec.rb @@ -2,25 +2,58 @@ RSpec.describe CoPlan::Ai do describe ".call" do - it "delegates to AiProviders::OpenAi and returns its response" do - allow(CoPlan::AiProviders::OpenAi).to receive(:call).and_return("ai output") + let(:captured) { {} } + let(:provider_lambda) { ->(messages:) { captured[:messages] = messages; "ai output" } } - result = described_class.call(system_prompt: "sys", user_content: "body") + around do |example| + original = CoPlan.configuration.ai_call + CoPlan.configuration.ai_call = provider_lambda + example.run + CoPlan.configuration.ai_call = original + end + + it "passes a messages array through to the configured callable" do + messages = [ + { role: :system, content: "sys" }, + { role: :user, content: "body" } + ] + + expect(described_class.call(messages: messages)).to eq("ai output") + expect(captured[:messages]).to eq(messages) + end + + it "supports the system:/user: convenience sugar" do + expect(described_class.call(system: "sys", user: "body")).to eq("ai output") + expect(captured[:messages]).to eq([ + { role: :system, content: "sys" }, + { role: :user, content: "body" } + ]) + end + + it "omits role messages whose sugar argument is nil" do + described_class.call(user: "body only") - expect(result).to eq("ai output") - expect(CoPlan::AiProviders::OpenAi).to have_received(:call).with( - system_prompt: "sys", - user_content: "body" - ) + expect(captured[:messages]).to eq([{ role: :user, content: "body only" }]) end - it "wraps provider errors in CoPlan::Ai::Error so callers don't know the provider" do - allow(CoPlan::AiProviders::OpenAi).to receive(:call) - .and_raise(CoPlan::AiProviders::OpenAi::Error, "rate limited") + it "raises NoProviderError when no callable is configured" do + CoPlan.configuration.ai_call = nil expect { - described_class.call(system_prompt: "sys", user_content: "body") - }.to raise_error(CoPlan::Ai::Error, "rate limited") + described_class.call(system: "sys", user: "body") + }.to raise_error(CoPlan::Ai::NoProviderError) + end + + it "wraps provider errors in CoPlan::Ai::Error" do + CoPlan.configuration.ai_call = ->(messages:) { raise "boom" } + + expect { + described_class.call(system: "sys", user: "body") + }.to raise_error(CoPlan::Ai::Error, /boom/) + end + + it "raises ArgumentError when no messages or sugar are supplied" do + expect { described_class.call }.to raise_error(ArgumentError, /messages:/) end end end From 457a42a9021410de9c248748222d7922b0fedd6b Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Tue, 2 Jun 2026 16:48:52 -0500 Subject: [PATCH 2/2] Address Codex feedback on #122 - Auto-wire OpenAI default unconditionally (lazy). The provider already resolves its key from Rails credentials -> ENV and raises CoPlan::Ai::Error at call time when neither is set, so credential-only deployments now wire up correctly instead of silently leaving ai_call nil. AI jobs still discard cleanly. - Drop unused ai_call_configured? helper (no longer meaningful now that the default is always wired; nothing called it). - Update docs/HOST_APP_GUIDE.md to remove the obsolete ai_api_key / ai_base_url / ai_model setters and document config.ai_call with the built-in OpenAI auto-wire behavior, so hosts following the guide don't NoMethodError on boot. Amp-Thread-ID: https://ampcode.com/threads/T-019e84ca-1bd1-7435-acbc-73b00095d896 Co-authored-by: Amp --- docs/HOST_APP_GUIDE.md | 26 +++++++++++++++++------ engine/app/services/coplan/ai.rb | 8 +++---- engine/lib/coplan/configuration.rb | 34 +++++++++++++++--------------- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/docs/HOST_APP_GUIDE.md b/docs/HOST_APP_GUIDE.md index 841d60a..230a056 100644 --- a/docs/HOST_APP_GUIDE.md +++ b/docs/HOST_APP_GUIDE.md @@ -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 ``` @@ -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) { diff --git a/engine/app/services/coplan/ai.rb b/engine/app/services/coplan/ai.rb index 9c16416..84465be 100644 --- a/engine/app/services/coplan/ai.rb +++ b/engine/app/services/coplan/ai.rb @@ -28,8 +28,9 @@ module CoPlan # CoPlan::Ai::Error, so callers can `discard_on CoPlan::Ai::Error` # without coupling to the underlying provider. # - # When no callable is configured, raises CoPlan::Ai::NoProviderError - # (a subclass of Error) so jobs still `discard_on` cleanly. + # 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 @@ -47,8 +48,7 @@ def self.call(messages: nil, system: nil, user: nil) callable = CoPlan.configuration.ai_call unless callable raise NoProviderError, - "No AI provider configured. Set CoPlan.configuration.ai_call in your host initializer " \ - "(or set OPENAI_API_KEY to auto-wire the built-in OpenAI plugin)." + "No AI provider configured. Set CoPlan.configuration.ai_call in your host initializer." end begin diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index a3724ec..5bb0eba 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -79,14 +79,17 @@ class Configuration # sites can `discard_on` without knowing the underlying provider. # # Hosts wire whatever backend they want β€” a built-in OpenAI plugin - # (auto-wired below when ENV["OPENAI_API_KEY"] is present), 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. + # (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. # - # When nil, CoPlan::Ai raises CoPlan::Ai::NoProviderError on use and - # AI-powered jobs (e.g. SummarizePlanJob) discard cleanly. + # 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:) { @@ -99,13 +102,13 @@ def initialize @error_reporter = ->(exception, context) { Rails.error.report(exception, context: context) } @notification_handler = nil @track_event = nil - # Built-in OpenAI default: auto-wired when an API key is available - # in the environment, so a standalone deployment gets working AI - # with zero config beyond setting OPENAI_API_KEY. Hosts can override - # this in their initializer to plug in a different backend. - @ai_call = if ENV["OPENAI_API_KEY"].present? - ->(messages:) { CoPlan::AiProviders::OpenAi.call(messages: messages) } - end + # 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 these instructions.' @agent_curl_prefix = 'curl -s -H "Authorization: Bearer $TOKEN"' @seed_plan_types = [] @@ -143,8 +146,5 @@ def web_push_configured? vapid_public_key.present? && vapid_private_key.present? && vapid_subject.present? end - def ai_call_configured? - !ai_call.nil? - end end end