PatientLLM
Integrate LLM APIs with your Ruby backend applications without blocking threads. This gem uses asynchronous HTTP requests to call LLM providers and handles the response via callbacks. It supports multiple API formats natively via PromptBuilder serializers:
- OpenAI Chat Completions (
:chat_completion) -- for OpenAI and compatible providers - OpenAI Responses (
:open_responses) -- for the newer OpenAI Responses API - Anthropic Messages (
:messages) -- for the Anthropic Claude API - Bedrock Converse (
:converse) -- for AWS Bedrock Converse API - Gemini (
:gemini) -- for the Google Gemini API
LLM API calls can take a long time to complete. With traditional synchronous HTTP clients, these requests tie up application threads while waiting for responses. This gem solves that problem by using async HTTP via PatientHttp, freeing up your threads to do other work while waiting for the LLM provider to respond.
Quick start
Configure a provider (built-in presets know each vendor's URL, API format, and authentication header):
PatientLLM.configure do |config|
config.provider :anthropic, preset: :anthropic, api_key: -> { ENV["ANTHROPIC_API_KEY"] }
end
Define an agent:
class TripPlannerAgent < PatientLLM::Agent
provider :anthropic
model "claude-sonnet-4-5"
system "You are a travel assistant. Be concise."
max_output_tokens 2_000
tool :weather, "Get the weather forecast for a city" do
param :city, :string, "City name", required: true
param :country, :string
end
output do
field :summary, :string, required: true
field :packing_list, array: :string
end
# Tool handler: the instance method with the tool's name.
def weather(city:, country: nil)
WeatherService.forecast(city: city, country: country)
end
# Runs in the worker when the final response arrives (after any tool rounds).
def completed(response)
trip = Trip.find(response.context[:trip_id])
trip.update!(plan: response.object, agent_state: response.state)
end
def failed(failure)
Rails.logger.error("Trip planning failed: #{failure.error_type} #{failure.}")
end
end
Call it:
# Asynchronously through your job system:
TripPlannerAgent.ask("Plan a weekend in NYC", context: {trip_id: trip.id})
# Continue a saved conversation:
TripPlannerAgent.continue(trip.agent_state, "Make it kid-friendly", context: {trip_id: trip.id})
# Inline (blocking) for consoles, development, and tests:
response = TripPlannerAgent.ask!("Plan a weekend in NYC", context: {trip_id: trip.id})
response.text # the raw response text
response.object["summary"] # parsed structured output per the output schema
response.usage.output_tokens # token usage
Everything the agent declares stays in code — only JSON-safe data (the serialized conversation, the agent's class name, your context, and the resolved request metadata: provider and serializer names, per-request overrides, and tool-loop counters) travels through the job queue. Tool handlers, API keys, and hooks are re-resolved in the worker process.
Prerequisites
This gem delegates HTTP dispatch to patient_http. In production you get a request handler by adding one of the job-system integrations:
For development, consoles, and tests you can execute requests synchronously in-process instead:
PatientHttp.inline! # register the inline handler (no job system needed)
Verify everything is wired at boot (surfaces missing secrets, preprocessors, or handlers as boot errors instead of dispatch-time job failures):
PatientLLM.verify_configuration!
Configuration
Provider presets
Presets bundle the vendor-specific details — base URL, API format, authentication header name, and key format:
PatientLLM.configure do |config|
config.provider :openai, preset: :openai, api_key: -> { ENV["OPENAI_API_KEY"] }
config.provider :anthropic, preset: :anthropic, api_key: -> { ENV["ANTHROPIC_API_KEY"] }
config.provider :gemini, preset: :gemini, api_key: -> { ENV["GEMINI_API_KEY"] }
config.provider :bedrock, preset: :bedrock_runtime, region: "us-east-1", preprocessors: :aws_sigv4
end
The api_key is registered as a PatientHttp secret named patient_llm.<provider>.api_key and referenced from the provider's authentication header. The key value is resolved on the processor side at dispatch time and is never serialized into job payloads. Prefer a lambda (resolved lazily, supports rotation); a plain String also works.
Bedrock supports two authentication styles: pass api_key: with an Amazon Bedrock API key (sent as a bearer token), or sign requests with IAM credentials using SigV4 as shown above — see request signing.
Every preset value can be overridden, so pointing a preset at a proxy or gateway is one keyword:
config.provider :my_proxy, preset: :openai, url: "https://llm-gateway.internal", api_key: -> { ENV["GATEWAY_KEY"] }
To keep managing your own secret names, pass a secret reference and no registration happens:
config.provider :openai, preset: :openai, api_key: PatientHttp.secret("openai.bearer_token")
Custom providers
Providers without a preset use the explicit form. Authentication headers must be secret references — inline API keys raise an error so keys can never end up serialized in the job queue:
PatientLLM.configure do |config|
config.provider :local, url: "http://localhost:1234", headers: {}
config.provider :custom,
url: "https://llm.internal",
headers: {"x-api-key" => PatientHttp.secret("custom.api_key")},
serializer: :messages,
timeout: 300, # per-provider request timeout (seconds)
max_tool_iterations: 25 # per-provider tool loop cap
end
# Register the referenced secret with PatientHttp (can be done from any initializer;
# registrations are applied to the job integration's configuration whenever it configures):
PatientHttp.register_secret("custom.api_key") { ENV["CUSTOM_API_KEY"] }
Provider options: url, headers, serializer, path, params (merged into every payload), preprocessors, timeout, max_tool_iterations, api_key (see provider presets), and region (used by region-templated presets such as :bedrock_runtime).
Every provider option (except name, preset, and api_key) may also be a callable, evaluated each time the provider is looked up — i.e. on every request — so values can be generated dynamically at runtime (api_key is consumed at registration time to build the authentication header; a callable key is resolved on the processor side at dispatch):
PatientLLM.configure do |config|
config.provider :gateway,
url: -> { LLMConfiguration.gateway_url },
timeout: -> { LLMConfiguration.request_timeout }
end
Validation of a callable's result (serializer names, authentication headers) happens at lookup time instead of registration time.
Request signing (preprocessors)
Some providers require request signing rather than a static authentication header — for example, AWS Bedrock with SigV4, where a signature is computed over the final outgoing request. For these, register a request preprocessor on the PatientHttp configuration and reference it by name from the provider. Like secrets, only the preprocessor name is serialized into the job queue; the signing logic and credentials stay on the processor side.
For SigV4, PatientLLM::AwsRequestSigner is a ready-made callable that can be registered directly as the preprocessor:
PatientLLM.configure do |config|
config.provider :bedrock, preset: :bedrock_runtime, region: "us-east-1", preprocessors: :aws_sigv4
end
PatientHttp::Sidekiq.configure do |config|
config.register_preprocessor(:aws_sigv4, PatientLLM::AwsRequestSigner.new(
credentials: Aws::CredentialProviderChain.new
))
end
credentials: is required and accepts a credential chain (anything responding to resolve, like Aws::CredentialProviderChain; resolved lazily on the first request), a credentials provider (responding to credentials), or a static credentials object (responding to access_key_id and secret_access_key, like Aws::Credentials). The signing service: and region: can be passed explicitly; when omitted they are derived from each request's URL host for standard <service>.<region> AWS endpoints, including dual-stack api.aws hosts (bedrock-runtime.us-east-1.amazonaws.com signs as service "bedrock" in region "us-east-1"; bedrock-mantle.us-east-1.api.aws signs under its own "bedrock-mantle" service name).
The signer needs the aws-sigv4 gem, which is not a dependency of this gem — add it to your bundle (it is included with aws-sdk-core, which also provides the credential chain).
Multiple preprocessors can be given as an array; they run in order at dispatch time.
Agents
PatientLLM::Agent is the high-level way to use this gem: one class declaring the provider, model, generation settings, tools, output schema, and completion handling.
Declarations
class ResearchAgent < PatientLLM::Agent
provider :openai # a registered provider name
model "gpt-5"
system "You are a research assistant."
instructions "Cite your sources."
temperature 0.2
max_output_tokens 4_000
reasoning :medium # portable effort level, or reasoning budget_tokens: 8_000
max_tool_iterations 5 # tool loop cap for this agent (default 10)
extra guardrail_config: {guardrailIdentifier: "gr-1", guardrailVersion: "1"}
end
instructions sets system-level instructions for the request. On APIs without a separate instructions field they are appended to the system prompt.
extra sets provider-specific extra data on every session the agent builds. The recognized keys depend on the serializer used for the request — for example, the Bedrock Converse serializer maps guardrail_config, stop_sequences, additional_model_request_fields, and performance_config into the request payload (see the prompt_builder docs for each serializer's keys). Redeclaring extra in a subclass replaces the whole hash, and continue re-applies the agent's current value to restored sessions. A per-request ask(extra: {...}) replaces the declaration for that request; to add to it instead, merge explicitly: MyAgent.ask(msg, extra: MyAgent.extra.merge(...)). Session options such as extra: are only accepted when ask builds the session — passing them to continue, or together with a session:, raises ArgumentError.
Dynamic values
Every scalar declaration (provider, model, system, instructions, temperature, max_output_tokens, max_tool_iterations, and extra) also accepts a block in lieu of the argument, or a callable (anything responding to call) as the argument. The block or callable is evaluated each time the agent builds or sends a request, so the value can be generated dynamically at runtime:
class ResearchAgent < PatientLLM::Agent
extra { LLMConfiguration.extra_hash }
temperature -> { AppConfig.llm_temperature }
end
Validation and coercion (extra's Hash check, provider's symbol coercion) apply to the block's result each time it is evaluated.
Inheritance
Subclasses inherit every declaration from their parent agent class, including tools and the output schema — so a base agent can hold shared configuration while subclasses specialize. Inheritance is live: getters fall back to the parent class, so declarations added to the parent later are visible to existing subclasses.
Override a setting by redeclaring it, or remove an inherited scalar setting by passing an explicit nil (tools and the output schema cannot be removed this way):
class BaseAgent < PatientLLM::Agent
provider :openai
model "gpt-5"
temperature 0.2
tool :search, "Search the knowledge base" do
param :query, :string, required: true
end
end
class CreativeAgent < BaseAgent
temperature 0.9 # override
end
class DefaultTemperatureAgent < BaseAgent
temperature nil # remove: use the provider's default temperature
end
Tools merge by name — redeclaring a tool in a subclass replaces the inherited declaration for that subclass only.
Tools
Declare the schema and define the handler together. The handler is the instance method with the tool's name, receiving the LLM-provided arguments as keywords:
tool :search, "Search the knowledge base" do
param :query, :string, "The search query", required: true
param :limit, :integer
end
def search(query:, limit: nil)
KnowledgeBase.search(query, limit: limit || 10) # String or JSON-able return value
end
A raw JSON Schema hash is also accepted: tool :search, "...", parameters: {...}.
When the model responds with tool calls, the gem automatically executes the matching methods, appends the results to the session, and re-issues the request — until the model returns a plain text response or the iteration cap is reached. Your completed hook only fires for the final response; define tool_round(response) to observe intermediate rounds. Raise PatientLLM::HaltError.new(content: "...") from a tool method to stop the loop and surface the content as the final response.
Hooks
Each hook receives a single object bundling the whole invocation:
completed(response)andtool_round(response)receive aPatientLLM::Agent::Responseexposingtext,object(parsed structured output;completedonly — an intermediatetool_roundresponse has none, and callingobjectthere raisesStructuredOutputError),state(the serializable session),usage,model,tool_calls,session,context(what you passed toask/continue),http_response(thePatientHttp::Response— status, headers, body, duration), andhttp_request_id. Incompletedthe HTTP exchange is the final request's; intool_roundit is that round's.failed(failure)receives aPatientLLM::Agent::Failureexposing theerror(witherror_type,message, anderror_classdelegated for convenience) alongside the samesession,state,context,http_response, andhttp_request_id. Thehttp_responseis nil for non-HTTP errors such as timeouts and connection failures.
Both objects support [] as a shorthand for reading a context value, so response[:trip_id] is the same as response.context[:trip_id] (and raises KeyError if the key was not passed to ask/continue).
def completed(response)
Trip.find(response[:trip_id]).update!(itinerary: response.object)
end
A tool method that declares a context: keyword receives the context too:
def search(query:, context:)
KnowledgeBase.for_user(context[:user_id]).search(query)
end
Inside hooks and tool methods the agent also exposes session and provider instance readers.
Overriding the hooks per request
Pass callback: to ask, continue, or ask! to send the hooks to another class instead of the agent. The agent still supplies the provider, model, tools, and output schema — only the hooks move:
class TripCallbacks
def completed(response)
Trip.find(response[:trip_id]).update!(itinerary: response.object)
end
def failed(failure)
ErrorReporter.notify(failure.error, trip_id: failure[:trip_id])
end
end
TripPlannerAgent.ask("Plan a weekend in NYC", context: {trip_id: trip.id}, callback: TripCallbacks)
The class is instantiated fresh in the worker for each invocation and may implement any subset of completed, failed, and tool_round; a hook it doesn't implement falls back to the agent's own. Only the class name travels through the queue, so it must be a named, resolvable class — an anonymous class raises ArgumentError, and one implementing none of the three hooks raises ArgumentError when ask is called.
[!NOTE] Tool handlers execute synchronously inside the callback worker (e.g. a Sidekiq job). Keep handlers fast to avoid blocking the worker pool. If a tool needs to do slow work, consider offloading it and using
HaltErrorto stop the auto-loop.
Structured output
output do
field :answer, :string, required: true
field :confidence, :number
field :sources, array: :object do
field :title, :string
field :url, :string
end
end
def completed(response)
response.object # => parsed Hash matching the schema
end
response.object raises PatientLLM::StructuredOutputError (with the raw text attached) if the response cannot be parsed. A raw JSON Schema hash is also accepted: output schema: {...}.
Multi-turn conversations
response.state is a JSON-native hash of the full conversation. Persist it anywhere, then continue:
def completed(response)
Conversation.find(response.context[:conversation_id]).update!(state: response.state)
end
# Later:
ResearchAgent.continue(conversation.state, "Tell me more about that", context: {conversation_id: conversation.id})
continue re-applies the agent's current model, system, tools, and output schema to the restored session, so deploying changes to an agent cleanly affects older conversations.
Passing an existing session: to ask is different: the session is sent as-is and the agent's declarations (model, system, tools, output schema) are not applied to it. Use continue for restored conversations.
Inline execution
ask! runs the request (and the whole tool loop) synchronously and returns the final response — no job system required. The completed/failed hooks still run, so tests exercise the real code paths:
response = ResearchAgent.ask!("What is the capital of France?")
response.text # => "Paris."
The low-level API
Agents compile down to this API; use it directly when you need full control.
Callback classes
Create a callback class with on_complete and on_error methods. Callbacks receive keyword arguments, and you only declare the ones you need — the dispatcher inspects your method signature and passes just those values (or everything if you declare **kwargs):
class LLMCallback
def on_complete(session:, provider:, llm_response:, callback_args:, http_response:, request_id:)
# session - the PromptBuilder::Session with the response already added
# provider - the provider name (String)
# llm_response - a PromptBuilder::Response with the assistant's response
# callback_args - a PatientHttp::CallbackArgs containing data you passed in the `ask` call
# http_response - the raw PatientHttp::Response
# request_id - the original request id (stable across tool-call iterations)
puts llm_response.text
puts "Tokens: #{llm_response.usage.input_tokens} in / #{llm_response.usage.output_tokens} out"
save_session_state(callback_args[:user_id], session.to_h)
end
def on_error(session:, provider:, callback_args:, error:, http_response:, request_id:)
# error is a PatientHttp::RequestError, ClientError (HTTP 4xx),
# or ServerError (HTTP 5xx). All respond to:
# error.error_type - :timeout, :connection, :ssl, :http_error, etc.
# error.message - human-readable message
# error.error_class - the original exception class (for RequestError)
# error.request_id
log_error(error.error_type, error.)
end
end
Each callback may declare any subset of the keywords below, in any order. PatientLLM.ask validates your callback's signatures up front and raises an ArgumentError if a method uses an unsupported name or a positional parameter, or if on_error omits its required error keyword.
| Callback | Supported keywords | Required |
|---|---|---|
on_complete |
session, provider, llm_response, callback_args, http_response, request_id |
— |
on_tool_use (optional) |
session, provider, llm_response, callback_args, http_response, request_id |
— |
on_error |
session, provider, callback_args, error, http_response, request_id |
error |
Making requests
Create a PromptBuilder::Session and call PatientLLM.ask to make an async request:
session = PromptBuilder::Session.new(model: "gpt-4o")
session.system("You are a helpful assistant.")
session.user("What is the capital of France?")
PatientLLM.ask(session, provider: :openai, callback: LLMCallback, callback_args: {
user_id: current_user.id
})
PromptBuilder::Session supports the full range of generation options, plus helpers for structured output, reasoning, and tools:
session.temperature = 0.7
session.max_output_tokens = 1000
session.json_output(my_json_schema) # structured output; parse with llm_response.parsed_json
session.think(effort: :medium) # portable reasoning configuration
session.use_tools("weather", "search") # attach tools registered on PromptBuilder.tool_registry
PatientLLM.ask accepts per-request overrides:
PatientLLM.ask(session,
provider: :openai,
callback: LLMCallback,
url: "http://localhost:1234", # Override the provider's base URL
serializer: :messages, # Override the API format
path: "/chat/completions", # Override the endpoint path
headers: {"X-Custom" => "value"}, # Additional HTTP headers
params: {max_completion_tokens: 1000}, # Additional request parameters
preprocessors: :aws_sigv4, # Replace the provider's request preprocessors
timeout: 600, # Request timeout in seconds
max_tool_iterations: 3 # Tool loop cap for this request
)
headers and params are merged on top of the provider's configured values, while the other options replace the provider defaults. All overrides are preserved across automatic tool-loop iterations.
Tool calling with the registry
Tools can also be registered on the global PromptBuilder.tool_registry with their handler, then attached to sessions by name — no schema duplication:
PromptBuilder.register_tool(
"weather",
description: "Get the current weather for a location",
parameters: {type: "object", properties: {location: {type: "string"}}, required: ["location"]}
) do |args|
WeatherService.lookup(args["location"])
end
session.use_tools("weather")
PatientLLM.ask(session, provider: :openai, callback: LLMCallback)
The automatic tool loop works the same as with agents. When the cap is exceeded, on_error receives a PatientHttp::RequestError whose error_type is :max_tool_iterations.
Inline execution
Run any request synchronously with PatientLLM.inline:
PatientLLM.inline do
PatientLLM.ask(session, provider: :openai, callback: LLMCallback)
end
Or register the PatientHttp inline handler globally for a console or test process with PatientHttp.inline!.
URL composition
The full request URL is built by concatenating the base URL (from the provider registry or the url: option) with the path. When you don't set path, it defaults to the path for the active serializer (v1/chat/completions for :chat_completion, v1/responses for :open_responses, v1/messages for :messages, model/{model}/converse for :converse, v1beta/models/{model}:generateContent for :gemini). A {model} placeholder in the path is replaced with the session's model, percent-encoded as a single path segment, at dispatch time. If your base URL already includes a /v1 prefix, override the path to avoid duplication:
PatientLLM.ask(session,
provider: :openai,
callback: LLMCallback,
url: "https://my-gateway.internal/openai/v1",
path: "chat/completions"
)
Serializing conversations
Sessions serialize to JSON for storage and later restoration:
# In your callback, save the state (the response is already in the session):
def on_complete(session:, callback_args:, **)
save_to_database(callback_args[:conversation_id], session.to_h)
end
# Later, restore and continue:
session = PromptBuilder::Session.from_h(load_from_database(conversation_id))
session.user("Tell me more about that.")
PatientLLM.ask(session, provider: :openai, callback: LLMCallback)
Session offload for large conversations
The serialized session travels through the job queue with each request. For conversations that can get large (e.g. with file attachments), configure automatic offloading to a PatientHttp payload store:
PatientLLM.configure do |config|
config.session_offload payload_store: :s3, threshold: 65_536
end
Sessions above the threshold are written to the store and passed by reference. Offloaded payloads are not deleted after use (so job retries keep working); use a store with managed expiration such as a Redis TTL or an S3 lifecycle rule.
[!NOTE] You can also set up encryption for your job payloads to ensure the entire serialized payload is always encrypted in the job queue. See the documentation for patient_http-sidekiq or patient_http-solid_queue for details.
Installation
This gem is not yet published to RubyGems. Add it from GitHub:
gem "patient_llm", github: "bdurand/patient_llm"
Then execute:
$ bundle
Contributing
Open a pull request on GitHub.
Please use the standardrb syntax and lint your code with standardrb --fix before submitting.
License
The gem is available as open source under the terms of the MIT License.