ruby_llm-agents 0.5.0 → 1.0.0.beta.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 +189 -31
- data/app/controllers/ruby_llm/agents/agents_controller.rb +136 -16
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +29 -9
- data/app/controllers/ruby_llm/agents/workflows_controller.rb +355 -0
- data/app/helpers/ruby_llm/agents/application_helper.rb +25 -0
- data/app/models/ruby_llm/agents/execution.rb +3 -0
- data/app/models/ruby_llm/agents/tenant_budget.rb +58 -15
- data/app/services/ruby_llm/agents/agent_registry.rb +51 -12
- data/app/views/layouts/ruby_llm/agents/application.html.erb +2 -29
- data/app/views/ruby_llm/agents/agents/_agent.html.erb +13 -1
- data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +235 -0
- data/app/views/ruby_llm/agents/agents/_config_embedder.html.erb +70 -0
- data/app/views/ruby_llm/agents/agents/_config_image_generator.html.erb +152 -0
- data/app/views/ruby_llm/agents/agents/_config_moderator.html.erb +63 -0
- data/app/views/ruby_llm/agents/agents/_config_speaker.html.erb +108 -0
- data/app/views/ruby_llm/agents/agents/_config_transcriber.html.erb +91 -0
- data/app/views/ruby_llm/agents/agents/_workflow.html.erb +1 -1
- data/app/views/ruby_llm/agents/agents/index.html.erb +74 -9
- data/app/views/ruby_llm/agents/agents/show.html.erb +18 -378
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +269 -15
- data/app/views/ruby_llm/agents/executions/show.html.erb +16 -0
- data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +93 -0
- data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +236 -0
- data/app/views/ruby_llm/agents/workflows/_structure_parallel.html.erb +76 -0
- data/app/views/ruby_llm/agents/workflows/_structure_pipeline.html.erb +74 -0
- data/app/views/ruby_llm/agents/workflows/_structure_router.html.erb +108 -0
- data/app/views/ruby_llm/agents/workflows/show.html.erb +442 -0
- data/config/routes.rb +1 -0
- data/lib/generators/ruby_llm_agents/agent_generator.rb +56 -7
- data/lib/generators/ruby_llm_agents/background_remover_generator.rb +110 -0
- data/lib/generators/ruby_llm_agents/embedder_generator.rb +107 -0
- data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +115 -0
- data/lib/generators/ruby_llm_agents/image_editor_generator.rb +108 -0
- data/lib/generators/ruby_llm_agents/image_generator_generator.rb +116 -0
- data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +178 -0
- data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +109 -0
- data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +103 -0
- data/lib/generators/ruby_llm_agents/image_variator_generator.rb +102 -0
- data/lib/generators/ruby_llm_agents/install_generator.rb +76 -4
- data/lib/generators/ruby_llm_agents/restructure_generator.rb +292 -0
- data/lib/generators/ruby_llm_agents/speaker_generator.rb +121 -0
- data/lib/generators/ruby_llm_agents/templates/add_execution_type_migration.rb.tt +8 -0
- data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +99 -84
- data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +42 -40
- data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +26 -0
- data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +50 -0
- data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +26 -0
- data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +20 -0
- data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +38 -0
- data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +139 -0
- data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +21 -0
- data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +20 -0
- data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +20 -0
- data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +49 -0
- data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +53 -0
- data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +44 -0
- data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +41 -0
- data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +45 -0
- data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +35 -0
- data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +47 -0
- data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +50 -0
- data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +44 -0
- data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +38 -0
- data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +33 -0
- data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +228 -0
- data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +131 -0
- data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +255 -0
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +120 -0
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +102 -0
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +282 -0
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +228 -0
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +120 -0
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +110 -0
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +120 -0
- data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +212 -0
- data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +227 -0
- data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +251 -0
- data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +300 -0
- data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +56 -0
- data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +51 -0
- data/lib/generators/ruby_llm_agents/transcriber_generator.rb +107 -0
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +152 -1
- data/lib/ruby_llm/agents/audio/speaker.rb +553 -0
- data/lib/ruby_llm/agents/audio/transcriber.rb +669 -0
- data/lib/ruby_llm/agents/base_agent.rb +675 -0
- data/lib/ruby_llm/agents/core/base/moderation_dsl.rb +181 -0
- data/lib/ruby_llm/agents/core/base/moderation_execution.rb +274 -0
- data/lib/ruby_llm/agents/core/base.rb +135 -0
- data/lib/ruby_llm/agents/core/configuration.rb +981 -0
- data/lib/ruby_llm/agents/core/errors.rb +150 -0
- data/lib/ruby_llm/agents/{instrumentation.rb → core/instrumentation.rb} +22 -1
- data/lib/ruby_llm/agents/core/llm_tenant.rb +358 -0
- data/lib/ruby_llm/agents/{version.rb → core/version.rb} +1 -1
- data/lib/ruby_llm/agents/dsl/base.rb +110 -0
- data/lib/ruby_llm/agents/dsl/caching.rb +142 -0
- data/lib/ruby_llm/agents/dsl/reliability.rb +307 -0
- data/lib/ruby_llm/agents/dsl.rb +41 -0
- data/lib/ruby_llm/agents/image/analyzer/dsl.rb +130 -0
- data/lib/ruby_llm/agents/image/analyzer/execution.rb +402 -0
- data/lib/ruby_llm/agents/image/analyzer.rb +90 -0
- data/lib/ruby_llm/agents/image/background_remover/dsl.rb +154 -0
- data/lib/ruby_llm/agents/image/background_remover/execution.rb +240 -0
- data/lib/ruby_llm/agents/image/background_remover.rb +89 -0
- data/lib/ruby_llm/agents/image/concerns/image_operation_dsl.rb +91 -0
- data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +165 -0
- data/lib/ruby_llm/agents/image/editor/dsl.rb +56 -0
- data/lib/ruby_llm/agents/image/editor/execution.rb +207 -0
- data/lib/ruby_llm/agents/image/editor.rb +92 -0
- data/lib/ruby_llm/agents/image/generator/active_storage_support.rb +127 -0
- data/lib/ruby_llm/agents/image/generator/content_policy.rb +95 -0
- data/lib/ruby_llm/agents/image/generator/pricing.rb +353 -0
- data/lib/ruby_llm/agents/image/generator/templates.rb +124 -0
- data/lib/ruby_llm/agents/image/generator.rb +455 -0
- data/lib/ruby_llm/agents/image/pipeline/dsl.rb +213 -0
- data/lib/ruby_llm/agents/image/pipeline/execution.rb +382 -0
- data/lib/ruby_llm/agents/image/pipeline.rb +97 -0
- data/lib/ruby_llm/agents/image/transformer/dsl.rb +148 -0
- data/lib/ruby_llm/agents/image/transformer/execution.rb +223 -0
- data/lib/ruby_llm/agents/image/transformer.rb +95 -0
- data/lib/ruby_llm/agents/image/upscaler/dsl.rb +83 -0
- data/lib/ruby_llm/agents/image/upscaler/execution.rb +219 -0
- data/lib/ruby_llm/agents/image/upscaler.rb +81 -0
- data/lib/ruby_llm/agents/image/variator/dsl.rb +62 -0
- data/lib/ruby_llm/agents/image/variator/execution.rb +189 -0
- data/lib/ruby_llm/agents/image/variator.rb +80 -0
- data/lib/ruby_llm/agents/{alert_manager.rb → infrastructure/alert_manager.rb} +17 -22
- data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +145 -0
- data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +149 -0
- data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +68 -0
- data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +279 -0
- data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +275 -0
- data/lib/ruby_llm/agents/{execution_logger_job.rb → infrastructure/execution_logger_job.rb} +17 -1
- data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/executor.rb +2 -1
- data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/retry_strategy.rb +9 -3
- data/lib/ruby_llm/agents/{reliability.rb → infrastructure/reliability.rb} +11 -21
- data/lib/ruby_llm/agents/pipeline/builder.rb +215 -0
- data/lib/ruby_llm/agents/pipeline/context.rb +255 -0
- data/lib/ruby_llm/agents/pipeline/executor.rb +86 -0
- data/lib/ruby_llm/agents/pipeline/middleware/base.rb +124 -0
- data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +95 -0
- data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +171 -0
- data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +415 -0
- data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +276 -0
- data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +196 -0
- data/lib/ruby_llm/agents/pipeline.rb +68 -0
- data/lib/ruby_llm/agents/{engine.rb → rails/engine.rb} +79 -11
- data/lib/ruby_llm/agents/results/background_removal_result.rb +286 -0
- data/lib/ruby_llm/agents/{result.rb → results/base.rb} +73 -1
- data/lib/ruby_llm/agents/results/embedding_result.rb +243 -0
- data/lib/ruby_llm/agents/results/image_analysis_result.rb +314 -0
- data/lib/ruby_llm/agents/results/image_edit_result.rb +250 -0
- data/lib/ruby_llm/agents/results/image_generation_result.rb +346 -0
- data/lib/ruby_llm/agents/results/image_pipeline_result.rb +399 -0
- data/lib/ruby_llm/agents/results/image_transform_result.rb +251 -0
- data/lib/ruby_llm/agents/results/image_upscale_result.rb +255 -0
- data/lib/ruby_llm/agents/results/image_variation_result.rb +237 -0
- data/lib/ruby_llm/agents/results/moderation_result.rb +158 -0
- data/lib/ruby_llm/agents/results/speech_result.rb +338 -0
- data/lib/ruby_llm/agents/results/transcription_result.rb +408 -0
- data/lib/ruby_llm/agents/text/embedder.rb +444 -0
- data/lib/ruby_llm/agents/text/moderator.rb +237 -0
- data/lib/ruby_llm/agents/workflow/async.rb +220 -0
- data/lib/ruby_llm/agents/workflow/async_executor.rb +156 -0
- data/lib/ruby_llm/agents/{workflow.rb → workflow/orchestrator.rb} +6 -5
- data/lib/ruby_llm/agents/workflow/parallel.rb +34 -17
- data/lib/ruby_llm/agents/workflow/thread_pool.rb +185 -0
- data/lib/ruby_llm/agents.rb +86 -20
- metadata +172 -34
- data/lib/ruby_llm/agents/base/caching.rb +0 -40
- data/lib/ruby_llm/agents/base/cost_calculation.rb +0 -105
- data/lib/ruby_llm/agents/base/dsl.rb +0 -324
- data/lib/ruby_llm/agents/base/execution.rb +0 -366
- data/lib/ruby_llm/agents/base/reliability_dsl.rb +0 -82
- data/lib/ruby_llm/agents/base/reliability_execution.rb +0 -136
- data/lib/ruby_llm/agents/base/response_building.rb +0 -86
- data/lib/ruby_llm/agents/base/tool_tracking.rb +0 -57
- data/lib/ruby_llm/agents/base.rb +0 -210
- data/lib/ruby_llm/agents/budget_tracker.rb +0 -733
- data/lib/ruby_llm/agents/configuration.rb +0 -394
- /data/lib/ruby_llm/agents/{deprecations.rb → core/deprecations.rb} +0 -0
- /data/lib/ruby_llm/agents/{inflections.rb → core/inflections.rb} +0 -0
- /data/lib/ruby_llm/agents/{resolved_config.rb → core/resolved_config.rb} +0 -0
- /data/lib/ruby_llm/agents/{attempt_tracker.rb → infrastructure/attempt_tracker.rb} +0 -0
- /data/lib/ruby_llm/agents/{cache_helper.rb → infrastructure/cache_helper.rb} +0 -0
- /data/lib/ruby_llm/agents/{circuit_breaker.rb → infrastructure/circuit_breaker.rb} +0 -0
- /data/lib/ruby_llm/agents/{redactor.rb → infrastructure/redactor.rb} +0 -0
- /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/breaker_manager.rb +0 -0
- /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/execution_constraints.rb +0 -0
- /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/fallback_routing.rb +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
# Base error class for RubyLLM::Agents
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# ============================================================
|
|
9
|
+
# Pipeline Errors
|
|
10
|
+
# ============================================================
|
|
11
|
+
|
|
12
|
+
# Base class for pipeline-related errors
|
|
13
|
+
class PipelineError < Error; end
|
|
14
|
+
|
|
15
|
+
# ============================================================
|
|
16
|
+
# Reliability Errors
|
|
17
|
+
# ============================================================
|
|
18
|
+
|
|
19
|
+
# Base class for reliability-related errors
|
|
20
|
+
class ReliabilityError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when an error is retryable (transient)
|
|
23
|
+
class RetryableError < ReliabilityError; end
|
|
24
|
+
|
|
25
|
+
# Raised when a circuit breaker is open
|
|
26
|
+
class CircuitOpenError < ReliabilityError
|
|
27
|
+
# @return [String] The model that has an open circuit
|
|
28
|
+
attr_reader :model
|
|
29
|
+
|
|
30
|
+
def initialize(message = nil, model: nil)
|
|
31
|
+
@model = model
|
|
32
|
+
super(message || "Circuit breaker is open#{model ? " for #{model}" : ""}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Raised when total timeout is exceeded across all attempts
|
|
37
|
+
class TotalTimeoutError < ReliabilityError
|
|
38
|
+
# @return [Float] The timeout that was exceeded
|
|
39
|
+
attr_reader :timeout
|
|
40
|
+
|
|
41
|
+
# @return [Float] The elapsed time
|
|
42
|
+
attr_reader :elapsed
|
|
43
|
+
|
|
44
|
+
def initialize(message = nil, timeout: nil, elapsed: nil)
|
|
45
|
+
@timeout = timeout
|
|
46
|
+
@elapsed = elapsed
|
|
47
|
+
super(message || "Total timeout of #{timeout}s exceeded (elapsed: #{elapsed&.round(2)}s)")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Raised when all models (primary + fallbacks) fail
|
|
52
|
+
class AllModelsFailedError < ReliabilityError
|
|
53
|
+
# @return [Array<Hash>] Details of each failed attempt
|
|
54
|
+
attr_reader :attempts
|
|
55
|
+
|
|
56
|
+
def initialize(message = nil, attempts: [])
|
|
57
|
+
@attempts = attempts
|
|
58
|
+
models = attempts.map { |a| a[:model] }.compact.join(", ")
|
|
59
|
+
super(message || "All models failed: #{models}")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ============================================================
|
|
64
|
+
# Budget Errors
|
|
65
|
+
# ============================================================
|
|
66
|
+
|
|
67
|
+
# Base class for budget-related errors
|
|
68
|
+
class BudgetError < Error; end
|
|
69
|
+
|
|
70
|
+
# Raised when budget is exceeded
|
|
71
|
+
class BudgetExceededError < BudgetError
|
|
72
|
+
# @return [String, nil] The tenant ID
|
|
73
|
+
attr_reader :tenant_id
|
|
74
|
+
|
|
75
|
+
# @return [String, nil] The budget type (daily, monthly, etc.)
|
|
76
|
+
attr_reader :budget_type
|
|
77
|
+
|
|
78
|
+
def initialize(message = nil, tenant_id: nil, budget_type: nil)
|
|
79
|
+
@tenant_id = tenant_id
|
|
80
|
+
@budget_type = budget_type
|
|
81
|
+
super(message || "Budget exceeded#{tenant_id ? " for tenant #{tenant_id}" : ""}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ============================================================
|
|
86
|
+
# Configuration Errors
|
|
87
|
+
# ============================================================
|
|
88
|
+
|
|
89
|
+
# Raised for configuration issues
|
|
90
|
+
class ConfigurationError < Error; end
|
|
91
|
+
|
|
92
|
+
# Raised when content is flagged during moderation
|
|
93
|
+
#
|
|
94
|
+
# Contains the full moderation result and the phase where
|
|
95
|
+
# the content was flagged.
|
|
96
|
+
#
|
|
97
|
+
# @example Handling moderation errors
|
|
98
|
+
# begin
|
|
99
|
+
# result = MyAgent.call(message: user_input)
|
|
100
|
+
# rescue RubyLLM::Agents::ModerationError => e
|
|
101
|
+
# puts "Content blocked: #{e.flagged_categories.join(', ')}"
|
|
102
|
+
# puts "Phase: #{e.phase}"
|
|
103
|
+
# puts "Scores: #{e.category_scores}"
|
|
104
|
+
# end
|
|
105
|
+
#
|
|
106
|
+
# @api public
|
|
107
|
+
class ModerationError < Error
|
|
108
|
+
# @return [Object] The raw moderation result from RubyLLM
|
|
109
|
+
attr_reader :moderation_result
|
|
110
|
+
|
|
111
|
+
# @return [Symbol] The phase where content was flagged (:input or :output)
|
|
112
|
+
attr_reader :phase
|
|
113
|
+
|
|
114
|
+
# Creates a new ModerationError
|
|
115
|
+
#
|
|
116
|
+
# @param moderation_result [Object] The moderation result from RubyLLM
|
|
117
|
+
# @param phase [Symbol] The phase where content was flagged
|
|
118
|
+
def initialize(moderation_result, phase)
|
|
119
|
+
@moderation_result = moderation_result
|
|
120
|
+
@phase = phase
|
|
121
|
+
|
|
122
|
+
categories = moderation_result.flagged_categories
|
|
123
|
+
category_list = categories.respond_to?(:join) ? categories.join(", ") : categories.to_s
|
|
124
|
+
|
|
125
|
+
super("Content flagged during #{phase} moderation: #{category_list}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns the flagged categories from the moderation result
|
|
129
|
+
#
|
|
130
|
+
# @return [Array<String, Symbol>] List of flagged categories
|
|
131
|
+
def flagged_categories
|
|
132
|
+
moderation_result.flagged_categories
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Returns the category scores from the moderation result
|
|
136
|
+
#
|
|
137
|
+
# @return [Hash{String, Symbol => Float}] Category to score mapping
|
|
138
|
+
def category_scores
|
|
139
|
+
moderation_result.category_scores
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Returns whether the moderation result was flagged
|
|
143
|
+
#
|
|
144
|
+
# @return [Boolean] Always true for ModerationError
|
|
145
|
+
def flagged?
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -627,6 +627,9 @@ module RubyLLM
|
|
|
627
627
|
# during multi-turn conversations (when tools are used)
|
|
628
628
|
tool_calls_data = respond_to?(:accumulated_tool_calls) ? accumulated_tool_calls : []
|
|
629
629
|
|
|
630
|
+
# Extract thinking data if present
|
|
631
|
+
thinking_data = safe_extract_thinking_data(response)
|
|
632
|
+
|
|
630
633
|
{
|
|
631
634
|
input_tokens: safe_response_value(response, :input_tokens),
|
|
632
635
|
output_tokens: safe_response_value(response, :output_tokens),
|
|
@@ -637,7 +640,7 @@ module RubyLLM
|
|
|
637
640
|
response: safe_serialize_response(response),
|
|
638
641
|
tool_calls: tool_calls_data || [],
|
|
639
642
|
tool_calls_count: tool_calls_data&.size || 0
|
|
640
|
-
}.compact
|
|
643
|
+
}.merge(thinking_data).compact
|
|
641
644
|
end
|
|
642
645
|
|
|
643
646
|
# Extracts finish reason from response, normalizing to standard values
|
|
@@ -665,6 +668,24 @@ module RubyLLM
|
|
|
665
668
|
end
|
|
666
669
|
end
|
|
667
670
|
|
|
671
|
+
# Extracts thinking data from response
|
|
672
|
+
#
|
|
673
|
+
# Handles different response structures from various providers.
|
|
674
|
+
# The thinking object typically has text, signature, and tokens.
|
|
675
|
+
#
|
|
676
|
+
# @param response [RubyLLM::Message] The LLM response
|
|
677
|
+
# @return [Hash] Thinking data (empty if none present)
|
|
678
|
+
def safe_extract_thinking_data(response)
|
|
679
|
+
thinking = safe_response_value(response, :thinking)
|
|
680
|
+
return {} unless thinking
|
|
681
|
+
|
|
682
|
+
{
|
|
683
|
+
thinking_text: thinking.respond_to?(:text) ? thinking.text : thinking[:text],
|
|
684
|
+
thinking_signature: thinking.respond_to?(:signature) ? thinking.signature : thinking[:signature],
|
|
685
|
+
thinking_tokens: thinking.respond_to?(:tokens) ? thinking.tokens : thinking[:tokens]
|
|
686
|
+
}.compact
|
|
687
|
+
end
|
|
688
|
+
|
|
668
689
|
# Extracts routing/retry tracking data from attempt tracker
|
|
669
690
|
#
|
|
670
691
|
# Analyzes the execution attempts to determine:
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Agents
|
|
7
|
+
# DSL for declaring Rails models as LLM tenants
|
|
8
|
+
#
|
|
9
|
+
# Provides automatic budget management and usage tracking when included
|
|
10
|
+
# in ActiveRecord models. Models using this concern can be passed as
|
|
11
|
+
# the `tenant:` parameter to agents.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# class Organization < ApplicationRecord
|
|
15
|
+
# llm_tenant
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example With custom ID method
|
|
19
|
+
# class Organization < ApplicationRecord
|
|
20
|
+
# llm_tenant id: :slug
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example With auto-created budget
|
|
24
|
+
# class Organization < ApplicationRecord
|
|
25
|
+
# llm_tenant id: :slug, budget: true
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example With limits (auto-creates budget)
|
|
29
|
+
# class Organization < ApplicationRecord
|
|
30
|
+
# llm_tenant(
|
|
31
|
+
# id: :slug,
|
|
32
|
+
# name: :company_name,
|
|
33
|
+
# limits: {
|
|
34
|
+
# daily_cost: 100,
|
|
35
|
+
# monthly_cost: 1000,
|
|
36
|
+
# daily_executions: 500
|
|
37
|
+
# },
|
|
38
|
+
# enforcement: :hard
|
|
39
|
+
# )
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# @example With API keys from model columns/methods
|
|
43
|
+
# class Organization < ApplicationRecord
|
|
44
|
+
# encrypts :openai_api_key, :anthropic_api_key # Rails 7+ encryption
|
|
45
|
+
#
|
|
46
|
+
# llm_tenant(
|
|
47
|
+
# id: :slug,
|
|
48
|
+
# api_keys: {
|
|
49
|
+
# openai: :openai_api_key, # column name
|
|
50
|
+
# anthropic: :anthropic_api_key, # column name
|
|
51
|
+
# gemini: :fetch_gemini_key # custom method
|
|
52
|
+
# }
|
|
53
|
+
# )
|
|
54
|
+
#
|
|
55
|
+
# def fetch_gemini_key
|
|
56
|
+
# Vault.read("secret/#{slug}/gemini")
|
|
57
|
+
# end
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# @see RubyLLM::Agents::TenantBudget
|
|
61
|
+
# @api public
|
|
62
|
+
module LLMTenant
|
|
63
|
+
extend ActiveSupport::Concern
|
|
64
|
+
|
|
65
|
+
included do
|
|
66
|
+
# Executions tracked for this tenant
|
|
67
|
+
has_many :llm_executions,
|
|
68
|
+
class_name: "RubyLLM::Agents::Execution",
|
|
69
|
+
as: :tenant_record,
|
|
70
|
+
dependent: :nullify
|
|
71
|
+
|
|
72
|
+
# Budget association (optional)
|
|
73
|
+
has_one :llm_budget,
|
|
74
|
+
class_name: "RubyLLM::Agents::TenantBudget",
|
|
75
|
+
as: :tenant_record,
|
|
76
|
+
dependent: :destroy
|
|
77
|
+
|
|
78
|
+
# Store options at class level
|
|
79
|
+
class_attribute :llm_tenant_options, default: {}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class_methods do
|
|
83
|
+
# Declares this model as an LLM tenant
|
|
84
|
+
#
|
|
85
|
+
# @param id [Symbol] Method to call for tenant_id string (default: :id)
|
|
86
|
+
# @param name [Symbol] Method for budget display name (default: :to_s)
|
|
87
|
+
# @param budget [Boolean] Auto-create TenantBudget on model creation (default: false)
|
|
88
|
+
# @param limits [Hash] Default budget limits (implies budget: true)
|
|
89
|
+
# @param enforcement [Symbol] Budget enforcement mode (:none, :soft, :hard)
|
|
90
|
+
# @param inherit_global [Boolean] Inherit from global config (default: true)
|
|
91
|
+
# @param api_keys [Hash] Provider API keys mapping (e.g., { openai: :openai_api_key })
|
|
92
|
+
# @return [void]
|
|
93
|
+
def llm_tenant(id: :id, name: :to_s, budget: false, limits: nil, enforcement: nil, inherit_global: true, api_keys: nil)
|
|
94
|
+
self.llm_tenant_options = {
|
|
95
|
+
id: id,
|
|
96
|
+
name: name,
|
|
97
|
+
budget: budget || limits.present?,
|
|
98
|
+
limits: normalize_limits(limits),
|
|
99
|
+
enforcement: enforcement,
|
|
100
|
+
inherit_global: inherit_global,
|
|
101
|
+
api_keys: api_keys
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Auto-create budget callback
|
|
105
|
+
after_create :create_default_llm_budget if llm_tenant_options[:budget]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Normalizes the limits hash to internal column names
|
|
111
|
+
#
|
|
112
|
+
# @param limits [Hash, nil] User-provided limits
|
|
113
|
+
# @return [Hash] Normalized limits
|
|
114
|
+
def normalize_limits(limits)
|
|
115
|
+
return {} if limits.blank?
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
daily_cost: limits[:daily_cost],
|
|
119
|
+
monthly_cost: limits[:monthly_cost],
|
|
120
|
+
daily_tokens: limits[:daily_tokens],
|
|
121
|
+
monthly_tokens: limits[:monthly_tokens],
|
|
122
|
+
daily_executions: limits[:daily_executions],
|
|
123
|
+
monthly_executions: limits[:monthly_executions]
|
|
124
|
+
}.compact
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns the tenant_id string for this model
|
|
129
|
+
#
|
|
130
|
+
# @return [String] The tenant identifier
|
|
131
|
+
def llm_tenant_id
|
|
132
|
+
id_method = self.class.llm_tenant_options[:id] || :id
|
|
133
|
+
send(id_method).to_s
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Returns API keys resolved from the DSL configuration
|
|
137
|
+
#
|
|
138
|
+
# Maps provider names (e.g., :openai, :anthropic) to their resolved values
|
|
139
|
+
# by calling the configured method/column on this model instance.
|
|
140
|
+
#
|
|
141
|
+
# @return [Hash] Provider to API key mapping (e.g., { openai: "sk-..." })
|
|
142
|
+
# @example
|
|
143
|
+
# org.llm_api_keys
|
|
144
|
+
# # => { openai: "sk-abc123", anthropic: "sk-ant-xyz789" }
|
|
145
|
+
def llm_api_keys
|
|
146
|
+
api_keys_config = self.class.llm_tenant_options[:api_keys]
|
|
147
|
+
return {} if api_keys_config.blank?
|
|
148
|
+
|
|
149
|
+
api_keys_config.transform_values do |method_name|
|
|
150
|
+
value = send(method_name)
|
|
151
|
+
value.presence
|
|
152
|
+
end.compact
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Returns cost for a given period
|
|
156
|
+
#
|
|
157
|
+
# @param period [Symbol, Range, nil] Time period (:today, :this_month, etc.)
|
|
158
|
+
# @return [BigDecimal] Total cost
|
|
159
|
+
def llm_cost(period: nil)
|
|
160
|
+
scope = llm_executions
|
|
161
|
+
scope = apply_period_scope(scope, period) if period
|
|
162
|
+
scope.sum(:total_cost) || 0
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Returns cost for today
|
|
166
|
+
#
|
|
167
|
+
# @return [BigDecimal] Today's cost
|
|
168
|
+
def llm_cost_today
|
|
169
|
+
llm_cost(period: :today)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Returns cost for this month
|
|
173
|
+
#
|
|
174
|
+
# @return [BigDecimal] This month's cost
|
|
175
|
+
def llm_cost_this_month
|
|
176
|
+
llm_cost(period: :this_month)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Returns token count for a given period
|
|
180
|
+
#
|
|
181
|
+
# @param period [Symbol, Range, nil] Time period
|
|
182
|
+
# @return [Integer] Total tokens
|
|
183
|
+
def llm_tokens(period: nil)
|
|
184
|
+
scope = llm_executions
|
|
185
|
+
scope = apply_period_scope(scope, period) if period
|
|
186
|
+
scope.sum(:total_tokens) || 0
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Returns tokens for today
|
|
190
|
+
#
|
|
191
|
+
# @return [Integer] Today's tokens
|
|
192
|
+
def llm_tokens_today
|
|
193
|
+
llm_tokens(period: :today)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Returns tokens for this month
|
|
197
|
+
#
|
|
198
|
+
# @return [Integer] This month's tokens
|
|
199
|
+
def llm_tokens_this_month
|
|
200
|
+
llm_tokens(period: :this_month)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Returns execution count for a given period
|
|
204
|
+
#
|
|
205
|
+
# @param period [Symbol, Range, nil] Time period
|
|
206
|
+
# @return [Integer] Execution count
|
|
207
|
+
def llm_execution_count(period: nil)
|
|
208
|
+
scope = llm_executions
|
|
209
|
+
scope = apply_period_scope(scope, period) if period
|
|
210
|
+
scope.count
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Returns executions for today
|
|
214
|
+
#
|
|
215
|
+
# @return [Integer] Today's execution count
|
|
216
|
+
def llm_executions_today
|
|
217
|
+
llm_execution_count(period: :today)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Returns executions for this month
|
|
221
|
+
#
|
|
222
|
+
# @return [Integer] This month's execution count
|
|
223
|
+
def llm_executions_this_month
|
|
224
|
+
llm_execution_count(period: :this_month)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Returns a usage summary for a given period
|
|
228
|
+
#
|
|
229
|
+
# @param period [Symbol] Time period (default: :this_month)
|
|
230
|
+
# @return [Hash] Usage summary with cost, tokens, and executions
|
|
231
|
+
def llm_usage_summary(period: :this_month)
|
|
232
|
+
{
|
|
233
|
+
cost: llm_cost(period: period),
|
|
234
|
+
tokens: llm_tokens(period: period),
|
|
235
|
+
executions: llm_execution_count(period: period),
|
|
236
|
+
period: period
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Returns or builds the associated TenantBudget
|
|
241
|
+
#
|
|
242
|
+
# @return [TenantBudget] The budget record
|
|
243
|
+
def llm_budget
|
|
244
|
+
super || build_llm_budget(tenant_id: llm_tenant_id)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Configure budget with a block
|
|
248
|
+
#
|
|
249
|
+
# @yield [budget] The budget to configure
|
|
250
|
+
# @return [TenantBudget] The saved budget
|
|
251
|
+
def llm_configure_budget
|
|
252
|
+
budget = llm_budget
|
|
253
|
+
yield(budget) if block_given?
|
|
254
|
+
budget.save!
|
|
255
|
+
budget
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Returns the budget status from BudgetTracker
|
|
259
|
+
#
|
|
260
|
+
# @return [Hash] Budget status
|
|
261
|
+
def llm_budget_status
|
|
262
|
+
BudgetTracker.status(tenant_id: llm_tenant_id)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Checks if within budget for a given limit type
|
|
266
|
+
#
|
|
267
|
+
# @param type [Symbol] Limit type (:daily_cost, :monthly_cost, :daily_tokens, etc.)
|
|
268
|
+
# @return [Boolean] true if within budget
|
|
269
|
+
def llm_within_budget?(type: :daily_cost)
|
|
270
|
+
status = llm_budget_status
|
|
271
|
+
return true unless status[:enabled]
|
|
272
|
+
|
|
273
|
+
key = budget_status_key(type)
|
|
274
|
+
status.dig(key, :percentage_used).to_f < 100
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Returns remaining budget for a given limit type
|
|
278
|
+
#
|
|
279
|
+
# @param type [Symbol] Limit type
|
|
280
|
+
# @return [Numeric, nil] Remaining amount
|
|
281
|
+
def llm_remaining_budget(type: :daily_cost)
|
|
282
|
+
status = llm_budget_status
|
|
283
|
+
key = budget_status_key(type)
|
|
284
|
+
status.dig(key, :remaining)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Raises an error if over budget
|
|
288
|
+
#
|
|
289
|
+
# @raise [BudgetExceededError] if budget is exceeded
|
|
290
|
+
# @return [void]
|
|
291
|
+
def llm_check_budget!
|
|
292
|
+
BudgetTracker.check_budget!(self.class.name, tenant_id: llm_tenant_id)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private
|
|
296
|
+
|
|
297
|
+
# Applies a period scope to an execution query
|
|
298
|
+
#
|
|
299
|
+
# @param scope [ActiveRecord::Relation] The query scope
|
|
300
|
+
# @param period [Symbol, Range] The period to filter by
|
|
301
|
+
# @return [ActiveRecord::Relation] Filtered scope
|
|
302
|
+
def apply_period_scope(scope, period)
|
|
303
|
+
case period
|
|
304
|
+
when :today then scope.where(created_at: Time.current.all_day)
|
|
305
|
+
when :yesterday then scope.where(created_at: 1.day.ago.all_day)
|
|
306
|
+
when :this_week then scope.where(created_at: Time.current.all_week)
|
|
307
|
+
when :this_month then scope.where(created_at: Time.current.all_month)
|
|
308
|
+
when Range then scope.where(created_at: period)
|
|
309
|
+
else scope
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Maps user-friendly type to budget status key
|
|
314
|
+
#
|
|
315
|
+
# @param type [Symbol] User-friendly type
|
|
316
|
+
# @return [Symbol] Status key
|
|
317
|
+
def budget_status_key(type)
|
|
318
|
+
case type
|
|
319
|
+
when :daily_cost then :global_daily
|
|
320
|
+
when :monthly_cost then :global_monthly
|
|
321
|
+
when :daily_tokens then :global_daily_tokens
|
|
322
|
+
when :monthly_tokens then :global_monthly_tokens
|
|
323
|
+
when :daily_executions then :global_daily_executions
|
|
324
|
+
when :monthly_executions then :global_monthly_executions
|
|
325
|
+
else :global_daily
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Creates the default budget on model creation
|
|
330
|
+
#
|
|
331
|
+
# @return [void]
|
|
332
|
+
def create_default_llm_budget
|
|
333
|
+
return if self.class.llm_tenant_options.blank?
|
|
334
|
+
return if llm_budget&.persisted?
|
|
335
|
+
|
|
336
|
+
options = self.class.llm_tenant_options
|
|
337
|
+
limits = options[:limits] || {}
|
|
338
|
+
name_method = options[:name] || :to_s
|
|
339
|
+
|
|
340
|
+
budget = build_llm_budget(
|
|
341
|
+
tenant_id: llm_tenant_id,
|
|
342
|
+
name: send(name_method).to_s,
|
|
343
|
+
daily_limit: limits[:daily_cost],
|
|
344
|
+
monthly_limit: limits[:monthly_cost],
|
|
345
|
+
daily_token_limit: limits[:daily_tokens],
|
|
346
|
+
monthly_token_limit: limits[:monthly_tokens],
|
|
347
|
+
daily_execution_limit: limits[:daily_executions],
|
|
348
|
+
monthly_execution_limit: limits[:monthly_executions],
|
|
349
|
+
enforcement: options[:enforcement]&.to_s || "soft",
|
|
350
|
+
inherit_global_defaults: options.fetch(:inherit_global, true)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
budget.tenant_record = self
|
|
354
|
+
budget.save!
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module DSL
|
|
6
|
+
# Base DSL available to all agents.
|
|
7
|
+
#
|
|
8
|
+
# Provides common configuration methods that every agent type needs:
|
|
9
|
+
# - model: The LLM model to use
|
|
10
|
+
# - version: Cache invalidation version
|
|
11
|
+
# - description: Human-readable description
|
|
12
|
+
# - timeout: Request timeout
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# class MyAgent < RubyLLM::Agents::BaseAgent
|
|
16
|
+
# extend DSL::Base
|
|
17
|
+
#
|
|
18
|
+
# model "gpt-4o"
|
|
19
|
+
# version "2.0"
|
|
20
|
+
# description "A helpful agent"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
module Base
|
|
24
|
+
# @!group Configuration DSL
|
|
25
|
+
|
|
26
|
+
# Sets or returns the LLM model for this agent class
|
|
27
|
+
#
|
|
28
|
+
# @param value [String, nil] The model identifier to set
|
|
29
|
+
# @return [String] The current model setting
|
|
30
|
+
# @example
|
|
31
|
+
# model "gpt-4o"
|
|
32
|
+
def model(value = nil)
|
|
33
|
+
@model = value if value
|
|
34
|
+
@model || inherited_or_default(:model, default_model)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Sets or returns the version string for cache invalidation
|
|
38
|
+
#
|
|
39
|
+
# Change this when you want to invalidate cached results
|
|
40
|
+
# (e.g., after changing prompts or behavior).
|
|
41
|
+
#
|
|
42
|
+
# @param value [String, nil] Version string
|
|
43
|
+
# @return [String] The current version
|
|
44
|
+
# @example
|
|
45
|
+
# version "2.0"
|
|
46
|
+
def version(value = nil)
|
|
47
|
+
@version = value if value
|
|
48
|
+
@version || inherited_or_default(:version, "1.0")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Sets or returns the description for this agent class
|
|
52
|
+
#
|
|
53
|
+
# Useful for documentation and tool registration.
|
|
54
|
+
#
|
|
55
|
+
# @param value [String, nil] The description text
|
|
56
|
+
# @return [String, nil] The current description
|
|
57
|
+
# @example
|
|
58
|
+
# description "Searches the knowledge base for relevant documents"
|
|
59
|
+
def description(value = nil)
|
|
60
|
+
@description = value if value
|
|
61
|
+
@description || inherited_or_default(:description, nil)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Sets or returns the timeout in seconds for LLM requests
|
|
65
|
+
#
|
|
66
|
+
# @param value [Integer, nil] Timeout in seconds
|
|
67
|
+
# @return [Integer] The current timeout setting
|
|
68
|
+
# @example
|
|
69
|
+
# timeout 30
|
|
70
|
+
def timeout(value = nil)
|
|
71
|
+
@timeout = value if value
|
|
72
|
+
@timeout || inherited_or_default(:timeout, default_timeout)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @!endgroup
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Looks up setting from superclass or uses default
|
|
80
|
+
#
|
|
81
|
+
# @param method [Symbol] The method to call on superclass
|
|
82
|
+
# @param default [Object] Default value if not found
|
|
83
|
+
# @return [Object] The resolved value
|
|
84
|
+
def inherited_or_default(method, default)
|
|
85
|
+
return default unless superclass.respond_to?(method)
|
|
86
|
+
|
|
87
|
+
superclass.send(method)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the default model from configuration
|
|
91
|
+
#
|
|
92
|
+
# @return [String] The default model
|
|
93
|
+
def default_model
|
|
94
|
+
RubyLLM::Agents.configuration.default_model
|
|
95
|
+
rescue StandardError
|
|
96
|
+
"gpt-4o"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the default timeout from configuration
|
|
100
|
+
#
|
|
101
|
+
# @return [Integer] The default timeout
|
|
102
|
+
def default_timeout
|
|
103
|
+
RubyLLM::Agents.configuration.default_timeout
|
|
104
|
+
rescue StandardError
|
|
105
|
+
120
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|