FeatherAi
A Ruby gem for identifying birds from photos and audio using RubyLLM. FeatherAi adds multi-modal identification, location-aware results, multi-model consensus, and a Rails integration on top of RubyLLM.
Installation
Add to your Gemfile:
gem "feather-ai"
Or install directly:
gem install feather-ai
Configuration
FeatherAi.configure do |c|
c.provider = :anthropic # Default: :anthropic
c.model = "claude-sonnet-4-5" # Default: "claude-sonnet-4-5"
c.location = "Perth, WA" # Optional: biases results to local species
c.consensus_models = ["claude-sonnet-4-5", "claude-haiku-4-5"] # Models used in consensus mode
c.tips_model = "claude-haiku-4-5" # Model for photography tips (default)
c.media_resolution = :high # Image resolution sent to provider (default)
c.tools = [] # RubyLLM tools available to every identification (default: none)
end
RubyLLM must be configured with your provider credentials before using FeatherAi. See the RubyLLM docs for setup.
Usage
Basic Identification
Identify a bird from an image:
result = FeatherAi.identify("path/to/bird.jpg")
result.common_name # => "Western Magpie"
result.species # => "Gymnorhina tibicen"
result.family # => "Artamidae"
result.confidence # => :high
result.confident? # => true
result.region_native? # => true
Identify from audio:
result = FeatherAi.identify(nil, "path/to/bird_call.mp3")
Identify from multiple images at once:
result = FeatherAi.identify(["front.jpg", "side.jpg"])
Identify from both image and audio:
result = FeatherAi.identify("path/to/bird.jpg", "path/to/bird_call.mp3")
Location-Aware Results
Pass a location to bias the model toward species native to that region:
result = FeatherAi.identify("path/to/bird.jpg", location: "Perth, Western Australia")
result.region_native? # => true/false based on species range
A default location can also be set globally in configuration.
Ranked Candidates
Every identification returns up to three candidate species ranked by likelihood, with the top pick first:
result = FeatherAi.identify("path/to/bird.jpg")
result.candidates
# => [{ common_name: "Splendid Fairywren", species: "Malurus splendens", score: 0.9 },
# { common_name: "Superb Fairywren", species: "Malurus cyaneus", score: 0.1 }]
Grounded Identification (Tools)
Pass RubyLLM tools so the model can verify its identification against real data — for example a species/region lookup backed by your own database:
class SpeciesLookupTool < RubyLLM::Tool
description "Looks up whether a species occurs in a region"
param :species, desc: "Scientific species name"
def execute(species:)
Species.find_by(scientific_name: species)&.slice(:regions, :description) || { found: false }
end
end
result = FeatherAi.identify("path/to/bird.jpg", location: "Perth, WA", tools: [SpeciesLookupTool])
Tools can also be set globally via c.tools in configuration. Note: Gemini rejects structured-output schemas combined with tools on many models — the tools: option is tested against the Anthropic defaults.
Consensus Mode
Run identification through two models independently. When both agree on species, you get high confidence. When they disagree, you get the candidates:
result = FeatherAi.identify("path/to/bird.jpg", consensus: true)
if result.confident?
puts "Both models agree: #{result.species}"
else
puts "Models disagree:"
result.candidates.each { |c| puts " #{c[:common_name]} (#{c[:species]}) — #{c[:score]}" }
end
Consensus models are configurable:
FeatherAi.configure do |c|
c.consensus_models = ["claude-sonnet-4-5", "claude-haiku-4-5"]
end
Photography Tips
Results expose lazy-loaded photography tips for the identified species. The tips are only fetched (via a fast, cheap model) when you access them:
tips = result.photography_tips
tips[:time_of_day] # => "Early morning or late afternoon for soft light"
tips[:approach] # => "Move slowly and quietly, approach from below sight line"
tips[:settings] # => "1/500s or faster, f/2.8-f/4, ISO 400-1600"
tips[:habitat] # => "Open woodlands, grasslands, and suburban parks"
Result Object
All identification calls return a FeatherAi::Result:
| Method | Type | Description |
|---|---|---|
common_name |
String | Common name (e.g. "Western Magpie") |
species |
String | Scientific name (e.g. "Gymnorhina tibicen") |
family |
String | Bird family (e.g. "Artamidae") |
confidence |
Symbol | :high, :medium, or :low |
confident? |
Boolean | true when confidence is :high |
region_native? |
Boolean | Whether species is native to the given region |
candidates |
Array | Ranked candidate hashes (common_name, species, score); vote-share ranked on consensus disagreement |
photography_tips |
Hash | Lazy-loaded shooting advice |
to_h |
Hash | All fields as a plain hash |
Every result also carries observability data from the LLM call:
| Method | Type | Description |
|---|---|---|
reasoning |
String | Step-by-step visual analysis the model performed |
model_id |
String | Model that produced the identification |
input_tokens |
Integer | Tokens sent to the model |
output_tokens |
Integer | Tokens received from the model |
cost |
Float | Estimated USD cost (based on built-in rate tables, or nil) |
duration_ms |
Integer | Wall-clock time of the LLM call in milliseconds |
source |
Symbol | :vision, :audio, or :multimodal |
consensus_models |
Array | Models used when consensus mode was enabled |
Rails Integration
Setup
Run the install generator to scaffold the migration:
rails generate feather_ai:install
# or with a custom model name:
rails generate feather_ai:install observation
Run the migration:
rails db:migrate
Model
Add acts_as_sighting to your ActiveRecord model. The model must have a photo attribute (ActiveStorage) and a location string column:
class Sighting < ApplicationRecord
has_one_attached :photo
acts_as_sighting
end
The generator adds these columns to the model's table: common_name, species, family, confidence, region_native, candidates (jsonb). On existing tables, identify! persists ranked candidates only when a candidates column is present — add one in your own migration or skip it.
Identifying Records
Call identify! on any instance to run identification and persist the results:
sighting = Sighting.create!(photo: params[:photo], location: "Perth, WA")
result = sighting.identify!
sighting.common_name # => "Western Magpie"
sighting.species # => "Gymnorhina tibicen"
sighting.confident? # => true (delegated through result)
identify! downloads the attached photo, calls FeatherAi.identify, updates the record's identification columns, and returns the FeatherAi::Result.
Corrections
Users or moderators can correct AI identifications. First, run the corrections generator to add the necessary columns:
rails generate feather_ai:add_corrections
# or with a custom model name:
rails generate feather_ai:add_corrections observation
Then apply corrections to a record:
sighting.correct!(common_name: "Australian Magpie", species: "Gymnorhina tibicen dorsalis")
sighting.corrected? # => true
sighting.corrected_at # => 2026-03-25 12:00:00 UTC
sighting.correction_delta
# => { common_name: { from: "Western Magpie", to: "Australian Magpie" },
# species: { from: "Gymnorhina tibicen", to: "Gymnorhina tibicen dorsalis" } }
Correctable fields: common_name, species, family, confidence, region_native.
Instrumentation
When ActiveSupport::Notifications is available (e.g. in Rails), every identification emits an identify.feather_ai event. Without ActiveSupport the instrumentation is a no-op.
ActiveSupport::Notifications.subscribe("identify.feather_ai") do |_name, _start, _finish, _id, payload|
Rails.logger.info "Identified #{payload[:result].common_name} " \
"with model=#{payload[:model]} in #{payload[:result].duration_ms}ms"
end
Payload keys: model, location, has_image, image_count, has_audio, and result (the FeatherAi::Result).
Error Handling
FeatherAi raises specific error classes, all inheriting from FeatherAi::Error:
FeatherAi::ConfigurationError— invalid or missing configuration (e.g. no image or audio provided)FeatherAi::IdentificationError— failure during the LLM identification call
begin
FeatherAi.identify("path/to/bird.jpg")
rescue FeatherAi::ConfigurationError => e
# handle bad config
rescue FeatherAi::IdentificationError => e
# handle LLM failure
end
Development
bin/setup # Install dependencies
bundle exec rspec # Run tests
bundle exec rubocop # Lint
bundle exec rake # Tests + lint (same as CI)
bin/console # Interactive console with gem loaded
Tests use VCR + WebMock to record and replay LLM responses — no API keys are required to run the test suite.
Use FeatherAi.reset! to clear configuration between test examples:
after { FeatherAi.reset! }
Thread Safety
FeatherAi.configuration is a process-level singleton initialised lazily with ||=. Under MRI Ruby, the Global VM Lock (GVL) makes this safe in practice. If you use JRuby or Ractors, initialise configuration eagerly at boot time before spawning threads:
# In an initialiser or boot file — before any threads are created
FeatherAi.configure do |c|
c.provider = :anthropic
c.model = "claude-sonnet-4"
end
FeatherAi.identify is stateless per-call — each invocation constructs its own Identifier or Consensus instance and RubyLLM::Chat session. Concurrent calls are safe.
FeatherAi::Consensus parallelises model calls using Ruby threads (Thread.new), so two LLM requests run concurrently and the total wall-clock time is roughly that of the slower model, not the sum of both.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/Birdup-Australia/Feather.
License
The gem is available as open source under the terms of the MIT License.