ruby_llm-agents 3.4.0 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +48 -0
- data/app/controllers/ruby_llm/agents/agents_controller.rb +27 -4
- data/app/services/ruby_llm/agents/agent_registry.rb +3 -1
- data/app/views/ruby_llm/agents/agents/_config_router.html.erb +110 -0
- data/app/views/ruby_llm/agents/agents/index.html.erb +6 -0
- data/app/views/ruby_llm/agents/executions/show.html.erb +10 -0
- data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +8 -0
- data/lib/ruby_llm/agents/audio/speaker.rb +1 -1
- data/lib/ruby_llm/agents/audio/transcriber.rb +26 -15
- data/lib/ruby_llm/agents/audio/transcription_pricing.rb +226 -0
- data/lib/ruby_llm/agents/base_agent.rb +1 -2
- data/lib/ruby_llm/agents/core/configuration.rb +25 -1
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/pricing/data_store.rb +339 -0
- data/lib/ruby_llm/agents/pricing/helicone_adapter.rb +88 -0
- data/lib/ruby_llm/agents/pricing/litellm_adapter.rb +105 -0
- data/lib/ruby_llm/agents/pricing/llmpricing_adapter.rb +73 -0
- data/lib/ruby_llm/agents/pricing/openrouter_adapter.rb +90 -0
- data/lib/ruby_llm/agents/pricing/portkey_adapter.rb +94 -0
- data/lib/ruby_llm/agents/pricing/ruby_llm_adapter.rb +94 -0
- data/lib/ruby_llm/agents/routing/class_methods.rb +92 -0
- data/lib/ruby_llm/agents/routing/result.rb +74 -0
- data/lib/ruby_llm/agents/routing.rb +140 -0
- data/lib/ruby_llm/agents.rb +3 -0
- metadata +13 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Pricing
|
|
6
|
+
# Normalizes OpenRouter bulk model list into the common pricing format.
|
|
7
|
+
#
|
|
8
|
+
# OpenRouter prices are **strings** representing USD per token.
|
|
9
|
+
# This adapter converts them to Float.
|
|
10
|
+
#
|
|
11
|
+
# Coverage: 400+ text LLM models, some audio-capable chat models.
|
|
12
|
+
# No transcription, image generation, or embedding models.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# OpenRouterAdapter.find_model("openai/gpt-4o")
|
|
16
|
+
# # => { input_cost_per_token: 0.0000025, output_cost_per_token: 0.00001, source: :openrouter }
|
|
17
|
+
#
|
|
18
|
+
module OpenRouterAdapter
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
# Find and normalize pricing for a model
|
|
22
|
+
#
|
|
23
|
+
# @param model_id [String] The model identifier
|
|
24
|
+
# @return [Hash, nil] Normalized pricing hash or nil
|
|
25
|
+
def find_model(model_id)
|
|
26
|
+
models = DataStore.openrouter_data
|
|
27
|
+
return nil unless models.is_a?(Array) && models.any?
|
|
28
|
+
|
|
29
|
+
entry = find_by_id(models, model_id)
|
|
30
|
+
return nil unless entry
|
|
31
|
+
|
|
32
|
+
normalize(entry)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def find_by_id(models, model_id)
|
|
38
|
+
normalized = model_id.to_s.downcase
|
|
39
|
+
|
|
40
|
+
# Exact match by id field
|
|
41
|
+
entry = models.find { |m| m["id"]&.downcase == normalized }
|
|
42
|
+
return entry if entry
|
|
43
|
+
|
|
44
|
+
# Try without provider prefix (e.g., "gpt-4o" matches "openai/gpt-4o")
|
|
45
|
+
entry = models.find do |m|
|
|
46
|
+
id = m["id"].to_s.downcase
|
|
47
|
+
id.end_with?("/#{normalized}") || id == normalized
|
|
48
|
+
end
|
|
49
|
+
return entry if entry
|
|
50
|
+
|
|
51
|
+
# Try with common provider prefixes
|
|
52
|
+
prefixes = %w[openai anthropic google meta-llama mistralai cohere deepseek]
|
|
53
|
+
prefixes.each do |prefix|
|
|
54
|
+
entry = models.find { |m| m["id"]&.downcase == "#{prefix}/#{normalized}" }
|
|
55
|
+
return entry if entry
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def normalize(entry)
|
|
62
|
+
pricing = entry["pricing"]
|
|
63
|
+
return nil unless pricing.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
result = {source: :openrouter}
|
|
66
|
+
|
|
67
|
+
prompt_cost = safe_float(pricing["prompt"])
|
|
68
|
+
completion_cost = safe_float(pricing["completion"])
|
|
69
|
+
|
|
70
|
+
result[:input_cost_per_token] = prompt_cost if prompt_cost&.positive?
|
|
71
|
+
result[:output_cost_per_token] = completion_cost if completion_cost&.positive?
|
|
72
|
+
|
|
73
|
+
if pricing["image"]
|
|
74
|
+
image_cost = safe_float(pricing["image"])
|
|
75
|
+
result[:image_cost_raw] = image_cost if image_cost&.positive?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
(result.keys.size > 1) ? result : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def safe_float(value)
|
|
82
|
+
return nil if value.nil?
|
|
83
|
+
Float(value)
|
|
84
|
+
rescue ArgumentError, TypeError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Pricing
|
|
6
|
+
# Normalizes Portkey AI per-model pricing into the common format.
|
|
7
|
+
#
|
|
8
|
+
# Portkey prices are in **cents per token**. This adapter converts to
|
|
9
|
+
# USD per token for consistency with other adapters.
|
|
10
|
+
#
|
|
11
|
+
# Requires knowing the provider for a model, resolved via PROVIDER_MAP.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# PortkeyAdapter.find_model("gpt-4o")
|
|
15
|
+
# # => { input_cost_per_token: 0.0000025, output_cost_per_token: 0.00001, source: :portkey }
|
|
16
|
+
#
|
|
17
|
+
module PortkeyAdapter
|
|
18
|
+
extend self
|
|
19
|
+
|
|
20
|
+
PROVIDER_MAP = [
|
|
21
|
+
[/^(gpt-|o1|o3|o4|whisper|dall-e|tts-|chatgpt)/, "openai"],
|
|
22
|
+
[/^claude/, "anthropic"],
|
|
23
|
+
[/^gemini/, "google"],
|
|
24
|
+
[/^(mistral|codestral|pixtral|ministral)/, "mistralai"],
|
|
25
|
+
[/^llama/, "meta"],
|
|
26
|
+
[/^(command|embed)/, "cohere"],
|
|
27
|
+
[/^deepseek/, "deepseek"],
|
|
28
|
+
[/^(nova|titan)/, "amazon"]
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Find and normalize pricing for a model
|
|
32
|
+
#
|
|
33
|
+
# @param model_id [String] The model identifier
|
|
34
|
+
# @return [Hash, nil] Normalized pricing hash or nil
|
|
35
|
+
def find_model(model_id)
|
|
36
|
+
provider, model_name = resolve_provider(model_id)
|
|
37
|
+
return nil unless provider
|
|
38
|
+
|
|
39
|
+
raw = DataStore.portkey_data(provider, model_name)
|
|
40
|
+
return nil unless raw.is_a?(Hash) && raw["pay_as_you_go"]
|
|
41
|
+
|
|
42
|
+
normalize(raw)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def resolve_provider(model_id)
|
|
48
|
+
id = model_id.to_s
|
|
49
|
+
|
|
50
|
+
# Handle prefixed model IDs like "azure/gpt-4o" or "groq/llama-3"
|
|
51
|
+
if id.include?("/")
|
|
52
|
+
parts = id.split("/", 2)
|
|
53
|
+
return [parts[0], parts[1]]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
PROVIDER_MAP.each do |pattern, provider|
|
|
57
|
+
return [provider, id] if id.match?(pattern)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize(raw)
|
|
64
|
+
pag = raw["pay_as_you_go"]
|
|
65
|
+
return nil unless pag
|
|
66
|
+
|
|
67
|
+
result = {source: :portkey}
|
|
68
|
+
|
|
69
|
+
# Main text token pricing (cents → USD)
|
|
70
|
+
req_price = dig_price(pag, "request_token", "price")
|
|
71
|
+
resp_price = dig_price(pag, "response_token", "price")
|
|
72
|
+
result[:input_cost_per_token] = req_price / 100.0 if req_price&.positive?
|
|
73
|
+
result[:output_cost_per_token] = resp_price / 100.0 if resp_price&.positive?
|
|
74
|
+
|
|
75
|
+
# Additional units (audio tokens, etc.)
|
|
76
|
+
additional = pag["additional_units"]
|
|
77
|
+
if additional.is_a?(Hash)
|
|
78
|
+
audio_in = dig_price(additional, "request_audio_token", "price")
|
|
79
|
+
audio_out = dig_price(additional, "response_audio_token", "price")
|
|
80
|
+
result[:input_cost_per_audio_token] = audio_in / 100.0 if audio_in&.positive?
|
|
81
|
+
result[:output_cost_per_audio_token] = audio_out / 100.0 if audio_out&.positive?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
(result.keys.size > 1) ? result : nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dig_price(hash, *keys)
|
|
88
|
+
value = hash.dig(*keys)
|
|
89
|
+
value.is_a?(Numeric) ? value : nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Pricing
|
|
6
|
+
# Extracts pricing from the ruby_llm gem's built-in model registry.
|
|
7
|
+
#
|
|
8
|
+
# This is a local, zero-HTTP-cost source that provides pricing for
|
|
9
|
+
# models that ruby_llm knows about. It's the fastest adapter since
|
|
10
|
+
# all data is already loaded in-process.
|
|
11
|
+
#
|
|
12
|
+
# Uses RubyLLM::Models.find(model_id) which returns pricing as
|
|
13
|
+
# USD per million tokens.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# RubyLLMAdapter.find_model("gpt-4o")
|
|
17
|
+
# # => { input_cost_per_token: 0.0000025, output_cost_per_token: 0.00001, source: :ruby_llm }
|
|
18
|
+
#
|
|
19
|
+
module RubyLLMAdapter
|
|
20
|
+
extend self
|
|
21
|
+
|
|
22
|
+
# Find and normalize pricing for a model from ruby_llm's registry
|
|
23
|
+
#
|
|
24
|
+
# @param model_id [String] The model identifier
|
|
25
|
+
# @return [Hash, nil] Normalized pricing hash or nil
|
|
26
|
+
def find_model(model_id)
|
|
27
|
+
return nil unless defined?(::RubyLLM::Models)
|
|
28
|
+
|
|
29
|
+
model_info = ::RubyLLM::Models.find(model_id)
|
|
30
|
+
return nil unless model_info
|
|
31
|
+
|
|
32
|
+
normalize(model_info)
|
|
33
|
+
rescue
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def normalize(model_info)
|
|
40
|
+
pricing = model_info.pricing
|
|
41
|
+
return nil unless pricing
|
|
42
|
+
|
|
43
|
+
result = {source: :ruby_llm}
|
|
44
|
+
|
|
45
|
+
# Text tokens (per million → per token)
|
|
46
|
+
text_tokens = pricing.respond_to?(:text_tokens) ? pricing.text_tokens : nil
|
|
47
|
+
if text_tokens
|
|
48
|
+
input_per_million = text_tokens.respond_to?(:input) ? text_tokens.input : nil
|
|
49
|
+
output_per_million = text_tokens.respond_to?(:output) ? text_tokens.output : nil
|
|
50
|
+
|
|
51
|
+
if input_per_million.is_a?(Numeric) && input_per_million.positive?
|
|
52
|
+
result[:input_cost_per_token] = input_per_million / 1_000_000.0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if output_per_million.is_a?(Numeric) && output_per_million.positive?
|
|
56
|
+
result[:output_cost_per_token] = output_per_million / 1_000_000.0
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Audio tokens (per million → per token) if available
|
|
61
|
+
audio_tokens = pricing.respond_to?(:audio_tokens) ? pricing.audio_tokens : nil
|
|
62
|
+
if audio_tokens
|
|
63
|
+
audio_input = audio_tokens.respond_to?(:input) ? audio_tokens.input : nil
|
|
64
|
+
audio_output = audio_tokens.respond_to?(:output) ? audio_tokens.output : nil
|
|
65
|
+
|
|
66
|
+
if audio_input.is_a?(Numeric) && audio_input.positive?
|
|
67
|
+
result[:input_cost_per_audio_token] = audio_input / 1_000_000.0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if audio_output.is_a?(Numeric) && audio_output.positive?
|
|
71
|
+
result[:output_cost_per_audio_token] = audio_output / 1_000_000.0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Image pricing if available
|
|
76
|
+
images = pricing.respond_to?(:images) ? pricing.images : nil
|
|
77
|
+
if images
|
|
78
|
+
per_image = images.respond_to?(:input) ? images.input : nil
|
|
79
|
+
if per_image.is_a?(Numeric) && per_image.positive?
|
|
80
|
+
result[:input_cost_per_image] = per_image
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Mode from model type if available
|
|
85
|
+
if model_info.respond_to?(:type)
|
|
86
|
+
result[:mode] = model_info.type.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
(result.keys.size > 1) ? result : nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Routing
|
|
6
|
+
# Class-level DSL for defining routes and classification categories.
|
|
7
|
+
#
|
|
8
|
+
# Extended into any BaseAgent subclass that includes the Routing concern.
|
|
9
|
+
# Provides `route`, `default_route`, and accessor methods for route definitions.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# class SupportRouter < RubyLLM::Agents::BaseAgent
|
|
13
|
+
# include RubyLLM::Agents::Routing
|
|
14
|
+
#
|
|
15
|
+
# route :billing, "Billing, charges, refunds"
|
|
16
|
+
# route :technical, "Bugs, errors, crashes"
|
|
17
|
+
# default_route :general
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
module ClassMethods
|
|
21
|
+
# Define a classification route.
|
|
22
|
+
#
|
|
23
|
+
# @param name [Symbol] Route identifier
|
|
24
|
+
# @param description [String] What messages belong to this route
|
|
25
|
+
# @param agent [Class, nil] Optional agent class to map to this route
|
|
26
|
+
#
|
|
27
|
+
# @example Simple route
|
|
28
|
+
# route :billing, "Billing, charges, refunds"
|
|
29
|
+
#
|
|
30
|
+
# @example Route with agent mapping
|
|
31
|
+
# route :billing, "Billing questions", agent: BillingAgent
|
|
32
|
+
#
|
|
33
|
+
def route(name, description, agent: nil)
|
|
34
|
+
@routes ||= {}
|
|
35
|
+
@routes[name.to_sym] = {
|
|
36
|
+
description: description,
|
|
37
|
+
agent: agent
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Set the default route for unmatched classifications.
|
|
42
|
+
#
|
|
43
|
+
# @param name [Symbol] Default route name
|
|
44
|
+
# @param agent [Class, nil] Optional default agent class
|
|
45
|
+
#
|
|
46
|
+
def default_route(name, agent: nil)
|
|
47
|
+
@default_route_name = name.to_sym
|
|
48
|
+
@routes ||= {}
|
|
49
|
+
@routes[name.to_sym] ||= {
|
|
50
|
+
description: "Default / general category",
|
|
51
|
+
agent: agent
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns all defined routes (including inherited).
|
|
56
|
+
#
|
|
57
|
+
# @return [Hash{Symbol => Hash}] Route definitions
|
|
58
|
+
def routes
|
|
59
|
+
parent = superclass.respond_to?(:routes) ? superclass.routes : {}
|
|
60
|
+
parent.merge(@routes || {})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns the default route name (including inherited).
|
|
64
|
+
#
|
|
65
|
+
# @return [Symbol] The default route name
|
|
66
|
+
def default_route_name
|
|
67
|
+
@default_route_name || (superclass.respond_to?(:default_route_name) ? superclass.default_route_name : :general)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns :router for instrumentation/tracking.
|
|
71
|
+
#
|
|
72
|
+
# @return [Symbol]
|
|
73
|
+
def agent_type
|
|
74
|
+
:router
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Override call to accept message: as a named param.
|
|
78
|
+
#
|
|
79
|
+
# @param message [String, nil] The message to classify
|
|
80
|
+
# @param kwargs [Hash] Additional options
|
|
81
|
+
# @return [RoutingResult] The classification result
|
|
82
|
+
def call(message: nil, **kwargs, &block)
|
|
83
|
+
if message
|
|
84
|
+
super(**kwargs.merge(message: message), &block)
|
|
85
|
+
else
|
|
86
|
+
super(**kwargs, &block)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Routing
|
|
6
|
+
# Wraps a standard Result with routing-specific accessors.
|
|
7
|
+
#
|
|
8
|
+
# Delegates all standard Result methods (tokens, cost, timing, etc.)
|
|
9
|
+
# to the underlying result, adding only the route-specific interface.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# result = SupportRouter.call(message: "I was charged twice")
|
|
13
|
+
# result.route # => :billing
|
|
14
|
+
# result.agent_class # => BillingAgent (if mapped)
|
|
15
|
+
# result.success? # => true
|
|
16
|
+
# result.total_cost # => 0.0001
|
|
17
|
+
#
|
|
18
|
+
class RoutingResult < Result
|
|
19
|
+
# @return [Symbol] The classified route name
|
|
20
|
+
attr_reader :route
|
|
21
|
+
|
|
22
|
+
# @return [Class, nil] The mapped agent class (if defined via `agent:`)
|
|
23
|
+
attr_reader :agent_class
|
|
24
|
+
|
|
25
|
+
# @return [String] The raw text response from the LLM
|
|
26
|
+
attr_reader :raw_response
|
|
27
|
+
|
|
28
|
+
# Creates a new RoutingResult by wrapping a base Result with route data.
|
|
29
|
+
#
|
|
30
|
+
# @param base_result [Result] The standard Result from BaseAgent execution
|
|
31
|
+
# @param route_data [Hash] Parsed route information
|
|
32
|
+
# @option route_data [Symbol] :route The classified route name
|
|
33
|
+
# @option route_data [Class, nil] :agent_class Mapped agent class
|
|
34
|
+
# @option route_data [String] :raw_response Raw LLM text
|
|
35
|
+
def initialize(base_result:, route_data:)
|
|
36
|
+
super(
|
|
37
|
+
content: route_data,
|
|
38
|
+
input_tokens: base_result.input_tokens,
|
|
39
|
+
output_tokens: base_result.output_tokens,
|
|
40
|
+
input_cost: base_result.input_cost,
|
|
41
|
+
output_cost: base_result.output_cost,
|
|
42
|
+
total_cost: base_result.total_cost,
|
|
43
|
+
model_id: base_result.model_id,
|
|
44
|
+
chosen_model_id: base_result.chosen_model_id,
|
|
45
|
+
temperature: base_result.temperature,
|
|
46
|
+
started_at: base_result.started_at,
|
|
47
|
+
completed_at: base_result.completed_at,
|
|
48
|
+
duration_ms: base_result.duration_ms,
|
|
49
|
+
finish_reason: base_result.finish_reason,
|
|
50
|
+
streaming: base_result.streaming,
|
|
51
|
+
error_class: base_result.error_class,
|
|
52
|
+
error_message: base_result.error_message,
|
|
53
|
+
attempts_count: base_result.attempts_count
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@route = route_data[:route]
|
|
57
|
+
@agent_class = route_data[:agent_class]
|
|
58
|
+
@raw_response = route_data[:raw_response]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Converts the result to a hash including routing fields.
|
|
62
|
+
#
|
|
63
|
+
# @return [Hash] All result data plus route, agent_class, raw_response
|
|
64
|
+
def to_h
|
|
65
|
+
super.merge(
|
|
66
|
+
route: route,
|
|
67
|
+
agent_class: agent_class&.name,
|
|
68
|
+
raw_response: raw_response
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "routing/class_methods"
|
|
4
|
+
require_relative "routing/result"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Agents
|
|
8
|
+
# Adds classification & routing capabilities to any BaseAgent.
|
|
9
|
+
#
|
|
10
|
+
# Include this module in a BaseAgent subclass to get:
|
|
11
|
+
# - `route` DSL for defining classification categories
|
|
12
|
+
# - `default_route` for fallback classification
|
|
13
|
+
# - Auto-generated system/user prompts from route definitions
|
|
14
|
+
# - Structured output parsing to return a RoutingResult
|
|
15
|
+
#
|
|
16
|
+
# All existing BaseAgent features (caching, reliability, retries,
|
|
17
|
+
# fallback models, instrumentation, multi-tenancy) work unchanged.
|
|
18
|
+
#
|
|
19
|
+
# @example Class-based router
|
|
20
|
+
# class SupportRouter < RubyLLM::Agents::BaseAgent
|
|
21
|
+
# include RubyLLM::Agents::Routing
|
|
22
|
+
#
|
|
23
|
+
# model "gpt-4o-mini"
|
|
24
|
+
# temperature 0.0
|
|
25
|
+
#
|
|
26
|
+
# route :billing, "Billing, charges, refunds"
|
|
27
|
+
# route :technical, "Bugs, errors, crashes"
|
|
28
|
+
# default_route :general
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# result = SupportRouter.call(message: "I was charged twice")
|
|
32
|
+
# result.route # => :billing
|
|
33
|
+
#
|
|
34
|
+
# @example Inline classification
|
|
35
|
+
# route = RubyLLM::Agents::Routing.classify(
|
|
36
|
+
# message: "I was charged twice",
|
|
37
|
+
# routes: { billing: "Billing issues", technical: "Tech issues" },
|
|
38
|
+
# default: :general
|
|
39
|
+
# )
|
|
40
|
+
# # => :billing
|
|
41
|
+
#
|
|
42
|
+
module Routing
|
|
43
|
+
def self.included(base)
|
|
44
|
+
unless base < BaseAgent
|
|
45
|
+
raise ArgumentError, "#{base} must inherit from RubyLLM::Agents::BaseAgent to include Routing"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
base.extend(ClassMethods)
|
|
49
|
+
base.param(:message, required: false)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Classify a message without defining a router class.
|
|
53
|
+
#
|
|
54
|
+
# Creates an anonymous BaseAgent subclass with Routing included,
|
|
55
|
+
# calls it, and returns just the route symbol.
|
|
56
|
+
#
|
|
57
|
+
# @param message [String] The message to classify
|
|
58
|
+
# @param routes [Hash{Symbol => String}] Route names to descriptions
|
|
59
|
+
# @param default [Symbol] Default route (:general)
|
|
60
|
+
# @param model [String] LLM model to use ("gpt-4o-mini")
|
|
61
|
+
# @param options [Hash] Extra options passed to .call
|
|
62
|
+
# @return [Symbol] The classified route name
|
|
63
|
+
def self.classify(message:, routes:, default: :general, model: "gpt-4o-mini", **options)
|
|
64
|
+
router_model = model
|
|
65
|
+
router = Class.new(BaseAgent) do
|
|
66
|
+
include Routing
|
|
67
|
+
|
|
68
|
+
self.model router_model
|
|
69
|
+
temperature 0.0
|
|
70
|
+
|
|
71
|
+
routes.each do |name, desc|
|
|
72
|
+
route name, desc
|
|
73
|
+
end
|
|
74
|
+
default_route default
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
result = router.call(message: message, **options)
|
|
78
|
+
result.route
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Helper to get the auto-generated system prompt for routing.
|
|
82
|
+
# Use this in custom system_prompt overrides to include route definitions.
|
|
83
|
+
#
|
|
84
|
+
# @return [String] The auto-generated routing system prompt
|
|
85
|
+
def routing_system_prompt
|
|
86
|
+
default = self.class.default_route_name
|
|
87
|
+
|
|
88
|
+
<<~PROMPT.strip
|
|
89
|
+
You are a message classifier. Classify the user's message into exactly one of the following categories:
|
|
90
|
+
|
|
91
|
+
#{routing_categories_text}
|
|
92
|
+
|
|
93
|
+
If none of the categories clearly match, classify as: #{default}
|
|
94
|
+
|
|
95
|
+
Respond with ONLY the category name, nothing else.
|
|
96
|
+
PROMPT
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Helper to get formatted route categories for use in custom prompts.
|
|
100
|
+
#
|
|
101
|
+
# @return [String] Formatted list of route categories
|
|
102
|
+
def routing_categories_text
|
|
103
|
+
self.class.routes.map do |name, config|
|
|
104
|
+
"- #{name}: #{config[:description]}"
|
|
105
|
+
end.join("\n")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Auto-generated system_prompt (used if subclass doesn't override).
|
|
109
|
+
def system_prompt
|
|
110
|
+
super || routing_system_prompt
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Auto-generated user_prompt from the :message param.
|
|
114
|
+
def user_prompt
|
|
115
|
+
@ask_message || options[:message] || super
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Override process_response to parse the route from LLM output.
|
|
119
|
+
def process_response(response)
|
|
120
|
+
raw = response.content.to_s.strip.downcase.gsub(/[^a-z0-9_]/, "")
|
|
121
|
+
route_name = raw.to_sym
|
|
122
|
+
|
|
123
|
+
valid_routes = self.class.routes.keys
|
|
124
|
+
route_name = self.class.default_route_name unless valid_routes.include?(route_name)
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
route: route_name,
|
|
128
|
+
agent_class: self.class.routes.dig(route_name, :agent),
|
|
129
|
+
raw_response: response.content.to_s.strip
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Override build_result to return a RoutingResult.
|
|
134
|
+
def build_result(content, response, context)
|
|
135
|
+
base = super
|
|
136
|
+
RoutingResult.new(base_result: base, route_data: content)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
data/lib/ruby_llm/agents.rb
CHANGED
|
@@ -57,6 +57,9 @@ require_relative "agents/results/image_pipeline_result"
|
|
|
57
57
|
require_relative "agents/image/concerns/image_operation_dsl"
|
|
58
58
|
require_relative "agents/image/concerns/image_operation_execution"
|
|
59
59
|
|
|
60
|
+
# Routing concern (classification & routing)
|
|
61
|
+
require_relative "agents/routing"
|
|
62
|
+
|
|
60
63
|
# Text agents
|
|
61
64
|
require_relative "agents/text/embedder"
|
|
62
65
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_llm-agents
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- adham90
|
|
@@ -102,6 +102,7 @@ files:
|
|
|
102
102
|
- app/views/ruby_llm/agents/agents/_config_embedder.html.erb
|
|
103
103
|
- app/views/ruby_llm/agents/agents/_config_image_generator.html.erb
|
|
104
104
|
- app/views/ruby_llm/agents/agents/_config_moderator.html.erb
|
|
105
|
+
- app/views/ruby_llm/agents/agents/_config_router.html.erb
|
|
105
106
|
- app/views/ruby_llm/agents/agents/_config_speaker.html.erb
|
|
106
107
|
- app/views/ruby_llm/agents/agents/_config_transcriber.html.erb
|
|
107
108
|
- app/views/ruby_llm/agents/agents/_empty_state.html.erb
|
|
@@ -215,6 +216,7 @@ files:
|
|
|
215
216
|
- lib/ruby_llm/agents/audio/speech_client.rb
|
|
216
217
|
- lib/ruby_llm/agents/audio/speech_pricing.rb
|
|
217
218
|
- lib/ruby_llm/agents/audio/transcriber.rb
|
|
219
|
+
- lib/ruby_llm/agents/audio/transcription_pricing.rb
|
|
218
220
|
- lib/ruby_llm/agents/base_agent.rb
|
|
219
221
|
- lib/ruby_llm/agents/core/base.rb
|
|
220
222
|
- lib/ruby_llm/agents/core/base/callbacks.rb
|
|
@@ -282,6 +284,13 @@ files:
|
|
|
282
284
|
- lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb
|
|
283
285
|
- lib/ruby_llm/agents/pipeline/middleware/reliability.rb
|
|
284
286
|
- lib/ruby_llm/agents/pipeline/middleware/tenant.rb
|
|
287
|
+
- lib/ruby_llm/agents/pricing/data_store.rb
|
|
288
|
+
- lib/ruby_llm/agents/pricing/helicone_adapter.rb
|
|
289
|
+
- lib/ruby_llm/agents/pricing/litellm_adapter.rb
|
|
290
|
+
- lib/ruby_llm/agents/pricing/llmpricing_adapter.rb
|
|
291
|
+
- lib/ruby_llm/agents/pricing/openrouter_adapter.rb
|
|
292
|
+
- lib/ruby_llm/agents/pricing/portkey_adapter.rb
|
|
293
|
+
- lib/ruby_llm/agents/pricing/ruby_llm_adapter.rb
|
|
285
294
|
- lib/ruby_llm/agents/rails/engine.rb
|
|
286
295
|
- lib/ruby_llm/agents/results/background_removal_result.rb
|
|
287
296
|
- lib/ruby_llm/agents/results/base.rb
|
|
@@ -295,6 +304,9 @@ files:
|
|
|
295
304
|
- lib/ruby_llm/agents/results/image_variation_result.rb
|
|
296
305
|
- lib/ruby_llm/agents/results/speech_result.rb
|
|
297
306
|
- lib/ruby_llm/agents/results/transcription_result.rb
|
|
307
|
+
- lib/ruby_llm/agents/routing.rb
|
|
308
|
+
- lib/ruby_llm/agents/routing/class_methods.rb
|
|
309
|
+
- lib/ruby_llm/agents/routing/result.rb
|
|
298
310
|
- lib/ruby_llm/agents/text/embedder.rb
|
|
299
311
|
- lib/tasks/ruby_llm_agents.rake
|
|
300
312
|
homepage: https://github.com/adham90/ruby_llm-agents
|