activeagent 1.0.2 → 1.1.0
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/CHANGELOG.md +71 -0
- data/README.md +26 -0
- data/lib/active_agent/base.rb +4 -0
- data/lib/active_agent/concerns/view.rb +1 -1
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +27 -6
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +11 -1
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +15 -12
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +27 -7
- data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +9 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +1 -1
- data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +18 -2
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +15 -3
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +3 -1
- data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +4 -4
- data/lib/active_agent/dashboard/config/routes.rb +5 -64
- data/lib/active_agent/dashboard/engine.rb +19 -15
- data/lib/active_agent/dashboard.rb +13 -3
- data/lib/active_agent/model_capabilities.rb +89 -0
- data/lib/active_agent/providers/_base_provider.rb +23 -2
- data/lib/active_agent/providers/anthropic/request.rb +3 -1
- data/lib/active_agent/providers/anthropic/transforms.rb +88 -0
- data/lib/active_agent/providers/bedrock_provider.rb +2 -2
- data/lib/active_agent/providers/concerns/exception_handler.rb +12 -1
- data/lib/active_agent/providers/errors.rb +140 -0
- data/lib/active_agent/providers/mock/request.rb +1 -4
- data/lib/active_agent/providers/ollama/chat/transforms.rb +9 -3
- data/lib/active_agent/providers/open_ai/chat_provider.rb +3 -3
- data/lib/active_agent/providers/requesty/_types.rb +16 -0
- data/lib/active_agent/providers/requesty/options.rb +39 -0
- data/lib/active_agent/providers/requesty_provider.rb +63 -0
- data/lib/active_agent/railtie.rb +5 -0
- data/lib/active_agent/telemetry/configuration.rb +112 -172
- data/lib/active_agent/telemetry/instrumentation.rb +137 -14
- data/lib/active_agent/telemetry/reporter.rb +12 -165
- data/lib/active_agent/telemetry/span.rb +18 -245
- data/lib/active_agent/telemetry/tracer.rb +67 -70
- data/lib/active_agent/telemetry.rb +1 -2
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +5 -0
- data/lib/generators/active_agent/dashboard/install_generator.rb +30 -2
- data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +41 -4
- data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +20 -4
- metadata +39 -16
- data/lib/generators/active_agent/dashboard/install/install_generator.rb +0 -96
- data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +0 -89
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +0 -42
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +0 -38
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +0 -22
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +0 -53
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +0 -28
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +0 -43
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +0 -44
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +0 -56
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
# Per-model capability quirks, applied before a request reaches the
|
|
5
|
+
# provider. Vendors ship models that reject otherwise-standard sampling
|
|
6
|
+
# parameters (thinking-first models steered by prompting/effort instead)
|
|
7
|
+
# with an API 400 — this registry strips those parameters up front so an
|
|
8
|
+
# agent configured with a shared temperature keeps working across model
|
|
9
|
+
# switches.
|
|
10
|
+
#
|
|
11
|
+
# The built-in rules cover the known families; apps can extend the
|
|
12
|
+
# registry for new or self-hosted models:
|
|
13
|
+
#
|
|
14
|
+
# @example Register a custom rule
|
|
15
|
+
# ActiveAgent::ModelCapabilities.register(/\Amy-reasoning-model/, unsupported: [:temperature, :top_p])
|
|
16
|
+
#
|
|
17
|
+
# @example Disable sanitization entirely
|
|
18
|
+
# ActiveAgent::ModelCapabilities.enabled = false
|
|
19
|
+
module ModelCapabilities
|
|
20
|
+
SAMPLING_PARAMS = [ :temperature, :top_p ].freeze
|
|
21
|
+
|
|
22
|
+
# Model families that reject sampling parameters with a 400:
|
|
23
|
+
# - Anthropic thinking-first models (Opus 4.7+, Opus 5, Sonnet 5,
|
|
24
|
+
# Fable 5 / Mythos 5)
|
|
25
|
+
# - OpenAI reasoning models (o-series, GPT-5 family)
|
|
26
|
+
BUILTIN_RULES = [
|
|
27
|
+
{ pattern: /\Aclaude-(opus-5|opus-4-[78]|sonnet-5|fable-5|mythos-5)/, unsupported: SAMPLING_PARAMS },
|
|
28
|
+
{ pattern: /\A(o1|o3|o4)(-|$)/, unsupported: SAMPLING_PARAMS },
|
|
29
|
+
{ pattern: /\Agpt-5/, unsupported: SAMPLING_PARAMS }
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Master switch; on by default. Set false to send parameters through
|
|
34
|
+
# untouched (the vendor then enforces its own rules).
|
|
35
|
+
attr_writer :enabled
|
|
36
|
+
|
|
37
|
+
def enabled
|
|
38
|
+
return @enabled unless @enabled.nil?
|
|
39
|
+
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Registers an app-defined capability rule ahead of the built-ins.
|
|
44
|
+
#
|
|
45
|
+
# @param pattern [Regexp] matched against the model name
|
|
46
|
+
# @param unsupported [Array<Symbol>] parameter keys the model rejects
|
|
47
|
+
def register(pattern, unsupported:)
|
|
48
|
+
custom_rules << { pattern: pattern, unsupported: unsupported.map(&:to_sym) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def custom_rules
|
|
52
|
+
@custom_rules ||= []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reset!
|
|
56
|
+
@custom_rules = []
|
|
57
|
+
@enabled = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Array<Symbol>] parameter keys the model rejects
|
|
61
|
+
def unsupported_params(model)
|
|
62
|
+
return [] if model.nil?
|
|
63
|
+
|
|
64
|
+
(custom_rules + BUILTIN_RULES).each do |rule|
|
|
65
|
+
return rule[:unsupported] if model.to_s.match?(rule[:pattern])
|
|
66
|
+
end
|
|
67
|
+
[]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sampling_supported?(model)
|
|
71
|
+
(unsupported_params(model) & SAMPLING_PARAMS).empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Strips parameters the model rejects, in place. Returns the removed
|
|
75
|
+
# keys (empty when nothing applied).
|
|
76
|
+
#
|
|
77
|
+
# @param parameters [Hash] prepared prompt parameters (must carry :model)
|
|
78
|
+
# @return [Array<Symbol>] removed parameter keys
|
|
79
|
+
def sanitize!(parameters)
|
|
80
|
+
return [] unless enabled
|
|
81
|
+
return [] unless parameters.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
removed = unsupported_params(parameters[:model]).select { |key| parameters.key?(key) }
|
|
84
|
+
removed.each { |key| parameters.delete(key) }
|
|
85
|
+
removed
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -55,7 +55,13 @@ module ActiveAgent
|
|
|
55
55
|
:request, :message_stack, # Runtime
|
|
56
56
|
:stream_broadcaster, :streaming, # Callback (Streams)
|
|
57
57
|
:tools_function, # Callback (Tools)
|
|
58
|
-
:usage_stack
|
|
58
|
+
:usage_stack, # Usage Tracking
|
|
59
|
+
:max_tool_turns, :tool_turns # Tool-loop safety
|
|
60
|
+
|
|
61
|
+
# Upper bound on tool-calling round-trips within one generation. A
|
|
62
|
+
# model that keeps emitting tool calls otherwise recurses until the
|
|
63
|
+
# provider stops it — override per agent/prompt with max_tool_turns:.
|
|
64
|
+
DEFAULT_MAX_TOOL_TURNS = 25
|
|
59
65
|
|
|
60
66
|
# @return [String] e.g., "Anthropic", "OpenAI"
|
|
61
67
|
def self.service_name
|
|
@@ -106,6 +112,8 @@ module ActiveAgent
|
|
|
106
112
|
self.stream_broadcaster = kwargs.delete(:stream_broadcaster)
|
|
107
113
|
self.streaming = false
|
|
108
114
|
self.tools_function = kwargs.delete(:tools_function)
|
|
115
|
+
self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
|
|
116
|
+
self.tool_turns = 0
|
|
109
117
|
self.options = options_klass.new(kwargs.extract!(*options_klass.keys))
|
|
110
118
|
self.context = kwargs
|
|
111
119
|
self.message_stack = []
|
|
@@ -344,7 +352,7 @@ module ActiveAgent
|
|
|
344
352
|
message_stack.push(*api_messages)
|
|
345
353
|
end
|
|
346
354
|
|
|
347
|
-
if (tool_calls = process_prompt_finished_extract_function_calls)&.any?
|
|
355
|
+
if (tool_calls = process_prompt_finished_extract_function_calls)&.any? && tool_turn_allowed?
|
|
348
356
|
process_function_calls(tool_calls)
|
|
349
357
|
resolve_prompt
|
|
350
358
|
else
|
|
@@ -373,6 +381,19 @@ module ActiveAgent
|
|
|
373
381
|
end
|
|
374
382
|
end
|
|
375
383
|
|
|
384
|
+
# Counts a tool round-trip against the per-generation cap. When the
|
|
385
|
+
# cap is hit the loop finishes cleanly with the messages gathered so
|
|
386
|
+
# far (a partial result) instead of recursing indefinitely.
|
|
387
|
+
#
|
|
388
|
+
# @return [Boolean] whether another tool round-trip may run
|
|
389
|
+
def tool_turn_allowed?
|
|
390
|
+
self.tool_turns += 1
|
|
391
|
+
return true if max_tool_turns.nil? || tool_turns <= max_tool_turns
|
|
392
|
+
|
|
393
|
+
instrument("tool_turns_exceeded.active_agent", limit: max_tool_turns)
|
|
394
|
+
false
|
|
395
|
+
end
|
|
396
|
+
|
|
376
397
|
# @abstract
|
|
377
398
|
# @param api_response [Object]
|
|
378
399
|
# @return [Array<Message>, nil]
|
|
@@ -71,7 +71,9 @@ module ActiveAgent
|
|
|
71
71
|
# @raise [ArgumentError] when gem model validation fails
|
|
72
72
|
def initialize(**params)
|
|
73
73
|
# Step 1: Extract custom fields that gem doesn't support
|
|
74
|
-
|
|
74
|
+
# Read response_format without deleting - normalize_params will delete and convert it
|
|
75
|
+
# to output_config for json_schema, or drop it for other types.
|
|
76
|
+
@response_format = params[:response_format]
|
|
75
77
|
@stream = params.delete(:stream)
|
|
76
78
|
anthropic_beta = params.delete(:anthropic_beta)
|
|
77
79
|
|
|
@@ -31,6 +31,12 @@ module ActiveAgent
|
|
|
31
31
|
params[:tools] = normalize_tools(params[:tools]) if params[:tools]
|
|
32
32
|
params[:tool_choice] = normalize_tool_choice(params[:tool_choice]) if params[:tool_choice]
|
|
33
33
|
|
|
34
|
+
# Normalize json_schema response_format → output_config (Anthropic's native structured output field)
|
|
35
|
+
if params[:response_format]
|
|
36
|
+
output_config = normalize_response_format(params.delete(:response_format))
|
|
37
|
+
params[:output_config] = output_config if output_config
|
|
38
|
+
end
|
|
39
|
+
|
|
34
40
|
# Handle mcps parameter (common format) -> transforms to mcp_servers (provider format)
|
|
35
41
|
if params[:mcps]
|
|
36
42
|
params[:mcp_servers] = normalize_mcp_servers(params.delete(:mcps))
|
|
@@ -159,6 +165,87 @@ module ActiveAgent
|
|
|
159
165
|
end
|
|
160
166
|
end
|
|
161
167
|
|
|
168
|
+
# Normalizes response_format to Anthropic output_config structure.
|
|
169
|
+
#
|
|
170
|
+
# Supported (ActiveAgent common format):
|
|
171
|
+
# { type: "json_schema", json_schema: { name: "...", schema: {...}, strict: true } }
|
|
172
|
+
# → { format: { type: "json_schema", schema: {...} } }
|
|
173
|
+
#
|
|
174
|
+
# Notes:
|
|
175
|
+
# - Anthropic requires `additionalProperties: false` on all object schemas.
|
|
176
|
+
# This is auto-injected into any object schemas that don't have it set.
|
|
177
|
+
# - Anthropic does not use OpenAI's `name` or `strict` fields in output_config.format.
|
|
178
|
+
# - json_object is not handled here; it remains prompt-emulated.
|
|
179
|
+
# - text is not handled here; Anthropic returns plain text by default.
|
|
180
|
+
#
|
|
181
|
+
# @param format [Hash, Symbol, String] ActiveAgent common response_format
|
|
182
|
+
# @return [Hash] Anthropic output_config hash, or nil if not applicable
|
|
183
|
+
def normalize_response_format(format)
|
|
184
|
+
case format
|
|
185
|
+
when Hash
|
|
186
|
+
format_hash = format.deep_symbolize_keys
|
|
187
|
+
|
|
188
|
+
if format_hash[:type].to_s == "json_schema"
|
|
189
|
+
schema = format_hash[:json_schema]&.dig(:schema)
|
|
190
|
+
if schema
|
|
191
|
+
schema = inject_additional_properties(schema.deep_dup)
|
|
192
|
+
end
|
|
193
|
+
{
|
|
194
|
+
format: {
|
|
195
|
+
type: "json_schema",
|
|
196
|
+
schema: schema
|
|
197
|
+
}.compact
|
|
198
|
+
}
|
|
199
|
+
elsif format_hash[:type].to_s == "json_object"
|
|
200
|
+
# json_object is not handled here; it remains prompt-emulated.
|
|
201
|
+
nil
|
|
202
|
+
elsif format_hash[:type].to_s == "text"
|
|
203
|
+
# text is not handled here; it remains prompt-emulated.
|
|
204
|
+
nil
|
|
205
|
+
else
|
|
206
|
+
# Pass through (already properly structured or Anthropic native format)
|
|
207
|
+
format_hash
|
|
208
|
+
end
|
|
209
|
+
when Symbol, String
|
|
210
|
+
# Bare :json_schema without a schema cannot use native output_config.
|
|
211
|
+
# Anthropic requires a valid JSON Schema — return nil to fall back.
|
|
212
|
+
nil
|
|
213
|
+
else
|
|
214
|
+
format
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Recursively injects `additionalProperties: false` into all object schemas.
|
|
219
|
+
#
|
|
220
|
+
# Anthropic's structured output API requires this field on all object types.
|
|
221
|
+
# Only inserts when not already explicitly set by the user.
|
|
222
|
+
#
|
|
223
|
+
# @param schema [Hash] JSON Schema hash
|
|
224
|
+
# @return [Hash] schema with additionalProperties injected
|
|
225
|
+
def inject_additional_properties(schema)
|
|
226
|
+
return schema unless schema.is_a?(Hash)
|
|
227
|
+
|
|
228
|
+
if schema[:type] == "object" && !schema.key?(:additionalProperties)
|
|
229
|
+
schema[:additionalProperties] = false
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Recurse into nested schemas
|
|
233
|
+
schema[:properties]&.each_value { |prop| inject_additional_properties(prop) }
|
|
234
|
+
schema[:items]&.then { |items| inject_additional_properties(items) }
|
|
235
|
+
schema[:anyOf]&.each { |s| inject_additional_properties(s) }
|
|
236
|
+
schema[:oneOf]&.each { |s| inject_additional_properties(s) }
|
|
237
|
+
schema[:allOf]&.each { |s| inject_additional_properties(s) }
|
|
238
|
+
|
|
239
|
+
if schema[:definitions]
|
|
240
|
+
schema[:definitions].each_value { |defn| inject_additional_properties(defn) }
|
|
241
|
+
end
|
|
242
|
+
if schema[:"$defs"]
|
|
243
|
+
schema[:"$defs"].each_value { |defn| inject_additional_properties(defn) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
schema
|
|
247
|
+
end
|
|
248
|
+
|
|
162
249
|
# Merges consecutive same-role messages into single messages with multiple content blocks.
|
|
163
250
|
#
|
|
164
251
|
# Required by Anthropic API - consecutive messages with the same role must be combined.
|
|
@@ -437,6 +524,7 @@ module ActiveAgent
|
|
|
437
524
|
msg.delete(:model)
|
|
438
525
|
msg.delete(:stop_reason)
|
|
439
526
|
msg.delete(:stop_sequence)
|
|
527
|
+
msg.delete(:stop_details)
|
|
440
528
|
msg.delete(:type)
|
|
441
529
|
msg.delete(:usage)
|
|
442
530
|
end
|
|
@@ -15,11 +15,11 @@ module ActiveAgent
|
|
|
15
15
|
# bedrock:
|
|
16
16
|
# service: "Bedrock"
|
|
17
17
|
# aws_region: "eu-west-2"
|
|
18
|
-
# model: "eu.anthropic.claude-sonnet-
|
|
18
|
+
# model: "eu.anthropic.claude-sonnet-5-v1:0"
|
|
19
19
|
#
|
|
20
20
|
# @example Agent usage
|
|
21
21
|
# class SummaryAgent < ApplicationAgent
|
|
22
|
-
# generate_with :bedrock, model: "eu.anthropic.claude-sonnet-
|
|
22
|
+
# generate_with :bedrock, model: "eu.anthropic.claude-sonnet-5-v1:0"
|
|
23
23
|
#
|
|
24
24
|
# def summarize
|
|
25
25
|
# prompt(message: params[:message])
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
3
5
|
module ActiveAgent
|
|
4
6
|
module Providers
|
|
5
7
|
# Provides exception handling for provider operations.
|
|
@@ -54,7 +56,16 @@ module ActiveAgent
|
|
|
54
56
|
def with_exception_handling(&block)
|
|
55
57
|
yield
|
|
56
58
|
rescue => exception
|
|
57
|
-
|
|
59
|
+
# Vendor API failures are normalized into the framework taxonomy
|
|
60
|
+
# (Errors::RateLimited, Errors::ContextLengthExceeded, ...) so
|
|
61
|
+
# rescue_from policy is portable across providers; the original
|
|
62
|
+
# exception is preserved as #cause. Anything unrecognizable —
|
|
63
|
+
# including ordinary Ruby errors — passes through untouched.
|
|
64
|
+
exception = Errors::Taxonomy.normalize(
|
|
65
|
+
exception,
|
|
66
|
+
provider_tag: (tag_name if respond_to?(:tag_name))
|
|
67
|
+
)
|
|
68
|
+
rescue_with_handler(exception) || raise(exception)
|
|
58
69
|
nil # Discard handler return value to prevent polluting raw_response
|
|
59
70
|
end
|
|
60
71
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Providers
|
|
5
|
+
# Typed provider failures, normalized across vendor SDKs.
|
|
6
|
+
#
|
|
7
|
+
# Every vendor raises its own exception classes for the same underlying
|
|
8
|
+
# conditions (rate limits, context overflows, content filters, outages),
|
|
9
|
+
# which makes retry/backoff/fallback policy impossible to express
|
|
10
|
+
# portably. The taxonomy classifies vendor errors into a small set of
|
|
11
|
+
# framework types — the original exception is preserved as +#cause+, so
|
|
12
|
+
# nothing is lost.
|
|
13
|
+
#
|
|
14
|
+
# @example Portable retry policy
|
|
15
|
+
# rescue_from ActiveAgent::Providers::Errors::RateLimited do |error|
|
|
16
|
+
# retry_job wait: 30.seconds
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Fallback on outage
|
|
20
|
+
# rescue_from ActiveAgent::Providers::Errors::ServiceUnavailable do |error|
|
|
21
|
+
# FallbackAgent.with(params).ask.generate_later
|
|
22
|
+
# end
|
|
23
|
+
module Errors
|
|
24
|
+
# Base class for normalized provider failures.
|
|
25
|
+
class ProviderError < StandardError
|
|
26
|
+
# @return [Integer, nil] HTTP status from the vendor error, when known
|
|
27
|
+
attr_reader :status
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] provider tag (e.g. "Anthropic", "OpenAI::Chat")
|
|
30
|
+
attr_reader :provider_tag
|
|
31
|
+
|
|
32
|
+
def initialize(message = nil, status: nil, provider_tag: nil)
|
|
33
|
+
super(message)
|
|
34
|
+
@status = status
|
|
35
|
+
@provider_tag = provider_tag
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 429s / vendor rate & quota limits. Retryable with backoff.
|
|
40
|
+
class RateLimited < ProviderError; end
|
|
41
|
+
|
|
42
|
+
# The prompt exceeded the model's context window. Not retryable
|
|
43
|
+
# without shrinking the input.
|
|
44
|
+
class ContextLengthExceeded < ProviderError; end
|
|
45
|
+
|
|
46
|
+
# Invalid, expired, or unauthorized credentials (401/403).
|
|
47
|
+
class AuthenticationFailed < ProviderError; end
|
|
48
|
+
|
|
49
|
+
# The vendor's safety layer refused the request or response.
|
|
50
|
+
class ContentFiltered < ProviderError; end
|
|
51
|
+
|
|
52
|
+
# Vendor-side failure or overload (5xx, timeouts, connection drops).
|
|
53
|
+
# Retryable; a natural trigger for provider fallback.
|
|
54
|
+
class ServiceUnavailable < ProviderError; end
|
|
55
|
+
|
|
56
|
+
# Malformed or unsupported request the vendor rejected (400/422)
|
|
57
|
+
# that doesn't classify more specifically.
|
|
58
|
+
class InvalidRequest < ProviderError; end
|
|
59
|
+
|
|
60
|
+
# Classifies vendor SDK exceptions into the taxonomy. Unrecognizable
|
|
61
|
+
# exceptions (including ordinary Ruby errors) pass through untouched —
|
|
62
|
+
# only errors that look like vendor API failures are normalized.
|
|
63
|
+
module Taxonomy
|
|
64
|
+
# Vendor SDK class names (demodulized) → taxonomy class. Covers the
|
|
65
|
+
# official anthropic/openai gems and SDKs following their naming.
|
|
66
|
+
NAME_MAP = {
|
|
67
|
+
"RateLimitError" => RateLimited,
|
|
68
|
+
"AuthenticationError" => AuthenticationFailed,
|
|
69
|
+
"PermissionDeniedError" => AuthenticationFailed,
|
|
70
|
+
"ContentFilterError" => ContentFiltered,
|
|
71
|
+
"InternalServerError" => ServiceUnavailable,
|
|
72
|
+
"APIConnectionError" => ServiceUnavailable,
|
|
73
|
+
"APIConnectionTimeoutError" => ServiceUnavailable,
|
|
74
|
+
"APITimeoutError" => ServiceUnavailable,
|
|
75
|
+
"OverloadedError" => ServiceUnavailable,
|
|
76
|
+
"ServiceUnavailableError" => ServiceUnavailable,
|
|
77
|
+
"BadRequestError" => InvalidRequest,
|
|
78
|
+
"UnprocessableEntityError" => InvalidRequest
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
STATUS_MAP = {
|
|
82
|
+
400 => InvalidRequest,
|
|
83
|
+
401 => AuthenticationFailed,
|
|
84
|
+
403 => AuthenticationFailed,
|
|
85
|
+
408 => ServiceUnavailable,
|
|
86
|
+
422 => InvalidRequest,
|
|
87
|
+
429 => RateLimited,
|
|
88
|
+
529 => ServiceUnavailable # Anthropic "overloaded"
|
|
89
|
+
}.freeze
|
|
90
|
+
|
|
91
|
+
CONTEXT_LENGTH_PATTERN = /context length|context_length|maximum context|context window|too many tokens|prompt is too long|input (?:is )?too long/i
|
|
92
|
+
CONTENT_FILTER_PATTERN = /content (?:filter|policy|management)|filtered due to|blocked by|safety (?:system|filter)/i
|
|
93
|
+
|
|
94
|
+
class << self
|
|
95
|
+
# @param exception [Exception]
|
|
96
|
+
# @param provider_tag [String, nil]
|
|
97
|
+
# @return [Exception] a taxonomy error, or the original exception
|
|
98
|
+
# when it doesn't classify
|
|
99
|
+
def normalize(exception, provider_tag: nil)
|
|
100
|
+
return exception if exception.is_a?(ProviderError)
|
|
101
|
+
|
|
102
|
+
klass = classify(exception)
|
|
103
|
+
return exception unless klass
|
|
104
|
+
|
|
105
|
+
klass.new(exception.message, status: status_of(exception), provider_tag: provider_tag)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Class, nil]
|
|
109
|
+
def classify(exception)
|
|
110
|
+
name = exception.class.name.to_s.demodulize
|
|
111
|
+
status = status_of(exception)
|
|
112
|
+
api_error = NAME_MAP.key?(name) || !status.nil?
|
|
113
|
+
return nil unless api_error
|
|
114
|
+
|
|
115
|
+
message = exception.message.to_s
|
|
116
|
+
return ContextLengthExceeded if CONTEXT_LENGTH_PATTERN.match?(message)
|
|
117
|
+
return ContentFiltered if CONTENT_FILTER_PATTERN.match?(message)
|
|
118
|
+
|
|
119
|
+
NAME_MAP[name] || STATUS_MAP[status] || (status && status >= 500 ? ServiceUnavailable : nil)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Integer, nil]
|
|
123
|
+
def status_of(exception)
|
|
124
|
+
[ :status, :status_code, :http_status, :code ].each do |reader|
|
|
125
|
+
next unless exception.respond_to?(reader)
|
|
126
|
+
|
|
127
|
+
value = begin
|
|
128
|
+
exception.public_send(reader)
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
return value if value.is_a?(Integer)
|
|
133
|
+
end
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -22,16 +22,13 @@ module ActiveAgent
|
|
|
22
22
|
attribute :stream, :boolean, default: false
|
|
23
23
|
attribute :tools # Array of tool definitions
|
|
24
24
|
attribute :tool_choice # Tool choice configuration
|
|
25
|
+
attribute :response_format
|
|
25
26
|
|
|
26
27
|
# Common Format Compatibility
|
|
27
28
|
def message=(value)
|
|
28
29
|
self.messages ||= []
|
|
29
30
|
self.messages << value
|
|
30
31
|
end
|
|
31
|
-
|
|
32
|
-
def response_format
|
|
33
|
-
{ type: "text" }
|
|
34
|
-
end
|
|
35
32
|
end
|
|
36
33
|
end
|
|
37
34
|
end
|
|
@@ -55,12 +55,18 @@ module ActiveAgent
|
|
|
55
55
|
OpenAI::Chat::Transforms.normalize_messages(messages)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# Normalizes instructions using OpenAI transforms
|
|
58
|
+
# Normalizes instructions using OpenAI transforms, then remaps the
|
|
59
|
+
# role: OpenAI's transforms emit the "developer" role, but the chat
|
|
60
|
+
# templates of Ollama-served models (qwen, llama, gemma, …) only
|
|
61
|
+
# know "system" — a "developer" message is silently dropped, so the
|
|
62
|
+
# model never sees its instructions.
|
|
59
63
|
#
|
|
60
64
|
# @param instructions [Array<String>, String]
|
|
61
|
-
# @return [Array<
|
|
65
|
+
# @return [Array<Hash>] system messages
|
|
62
66
|
def normalize_instructions(instructions)
|
|
63
|
-
OpenAI::Chat::Transforms.normalize_instructions(instructions)
|
|
67
|
+
OpenAI::Chat::Transforms.normalize_instructions(instructions).map do |message|
|
|
68
|
+
message.is_a?(Hash) ? message.merge(role: "system") : message
|
|
69
|
+
end
|
|
64
70
|
end
|
|
65
71
|
|
|
66
72
|
# Cleans up serialized request for API submission
|
|
@@ -104,7 +104,7 @@ module ActiveAgent
|
|
|
104
104
|
|
|
105
105
|
# If we have a delta, we need to update a message in the stack
|
|
106
106
|
message = find_or_create_message(api_message.index)
|
|
107
|
-
|
|
107
|
+
message_merge_delta(message, api_message.delta.deep_to_h)
|
|
108
108
|
|
|
109
109
|
# Stream back content changes as they come in
|
|
110
110
|
if api_message.delta.content
|
|
@@ -140,7 +140,7 @@ module ActiveAgent
|
|
|
140
140
|
def process_function_calls(api_function_calls)
|
|
141
141
|
api_function_calls.each do |api_function_call|
|
|
142
142
|
content = instrument("tool_call.active_agent", tool_name: api_function_call.dig(:function, :name)) do
|
|
143
|
-
case api_function_call[:type]
|
|
143
|
+
case api_function_call[:type].to_s
|
|
144
144
|
when "function"
|
|
145
145
|
process_tool_call_function(api_function_call[:function])
|
|
146
146
|
else
|
|
@@ -245,7 +245,7 @@ module ActiveAgent
|
|
|
245
245
|
end
|
|
246
246
|
end
|
|
247
247
|
when String
|
|
248
|
-
hash[key] += value
|
|
248
|
+
hash[key] += value.to_s
|
|
249
249
|
else
|
|
250
250
|
hash[key] = value
|
|
251
251
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../open_ai/chat/_types"
|
|
4
|
+
require_relative "options"
|
|
5
|
+
|
|
6
|
+
module ActiveAgent
|
|
7
|
+
module Providers
|
|
8
|
+
module Requesty
|
|
9
|
+
# ActiveModel type for casting and serializing Requesty requests.
|
|
10
|
+
#
|
|
11
|
+
# Requesty is OpenAI-compatible, so requests use the same shape as the
|
|
12
|
+
# OpenAI Chat API. This delegates entirely to OpenAI::Chat::RequestType.
|
|
13
|
+
RequestType = ActiveAgent::Providers::OpenAI::Chat::RequestType
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../open_ai/options"
|
|
4
|
+
|
|
5
|
+
module ActiveAgent
|
|
6
|
+
module Providers
|
|
7
|
+
module Requesty
|
|
8
|
+
# Configuration options for the Requesty provider.
|
|
9
|
+
#
|
|
10
|
+
# Extends OpenAI::Options, overriding the base URL to point at Requesty's
|
|
11
|
+
# OpenAI-compatible gateway and resolving the API key from REQUESTY_API_KEY.
|
|
12
|
+
# Requesty does not use organization or project identifiers.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic configuration
|
|
15
|
+
# options = Options.new(api_key: ENV["REQUESTY_API_KEY"])
|
|
16
|
+
#
|
|
17
|
+
# @see https://docs.requesty.ai
|
|
18
|
+
# @see https://app.requesty.ai/api-keys Requesty API Keys
|
|
19
|
+
class Options < ActiveAgent::Providers::OpenAI::Options
|
|
20
|
+
# @!attribute base_url
|
|
21
|
+
# @return [String] API endpoint (default: "https://router.requesty.ai/v1")
|
|
22
|
+
attribute :base_url, :string, as: "https://router.requesty.ai/v1"
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def resolve_api_key(kwargs)
|
|
27
|
+
kwargs[:api_key] ||
|
|
28
|
+
kwargs[:access_token] ||
|
|
29
|
+
ENV["REQUESTY_API_KEY"] ||
|
|
30
|
+
ENV["REQUESTY_ACCESS_TOKEN"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Not used as part of Requesty
|
|
34
|
+
def resolve_organization_id(kwargs) = nil
|
|
35
|
+
def resolve_project_id(kwargs) = nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require_relative "_base_provider"
|
|
2
|
+
|
|
3
|
+
require_gem!(:openai, __FILE__)
|
|
4
|
+
|
|
5
|
+
require_relative "open_ai_provider"
|
|
6
|
+
require_relative "requesty/_types"
|
|
7
|
+
|
|
8
|
+
module ActiveAgent
|
|
9
|
+
module Providers
|
|
10
|
+
# Provides access to Requesty's OpenAI-compatible LLM gateway.
|
|
11
|
+
#
|
|
12
|
+
# Extends the OpenAI provider to work with Requesty's OpenAI-compatible API,
|
|
13
|
+
# enabling access to multiple AI models through a single interface using the
|
|
14
|
+
# +provider/model+ naming convention (e.g. +openai/gpt-4o-mini+).
|
|
15
|
+
#
|
|
16
|
+
# Requesty is a plain OpenAI-compatible gateway: requests, responses and
|
|
17
|
+
# transforms are identical to the OpenAI Chat API, so this provider reuses
|
|
18
|
+
# OpenAI::Chat::RequestType and OpenAI::Chat::Transforms directly. The only
|
|
19
|
+
# Requesty-specific configuration is the base URL and API key, which live in
|
|
20
|
+
# Requesty::Options.
|
|
21
|
+
#
|
|
22
|
+
# @example Configuration in active_agent.yml
|
|
23
|
+
# requesty:
|
|
24
|
+
# service: "Requesty"
|
|
25
|
+
# api_key: <%= ENV["REQUESTY_API_KEY"] %>
|
|
26
|
+
# model: "openai/gpt-4o-mini"
|
|
27
|
+
#
|
|
28
|
+
# @see OpenAI::ChatProvider
|
|
29
|
+
# @see https://docs.requesty.ai
|
|
30
|
+
class RequestyProvider < OpenAI::ChatProvider
|
|
31
|
+
# @return [String]
|
|
32
|
+
def self.service_name
|
|
33
|
+
"Requesty"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Class]
|
|
37
|
+
def self.options_klass
|
|
38
|
+
Requesty::Options
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [ActiveModel::Type::Value]
|
|
42
|
+
def self.prompt_request_type
|
|
43
|
+
OpenAI::Chat::RequestType.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [ActiveModel::Type::Value]
|
|
47
|
+
def self.embed_request_type
|
|
48
|
+
OpenAI::Embedding::RequestType.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
protected
|
|
52
|
+
|
|
53
|
+
# @see BaseProvider#api_response_normalize
|
|
54
|
+
# @param api_response [OpenAI::Models::ChatCompletion]
|
|
55
|
+
# @return [Hash] normalized response hash
|
|
56
|
+
def api_response_normalize(api_response)
|
|
57
|
+
return api_response unless api_response
|
|
58
|
+
|
|
59
|
+
OpenAI::Chat::Transforms.gem_to_hash(api_response)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/active_agent/railtie.rb
CHANGED
|
@@ -67,6 +67,11 @@ module ActiveAgent
|
|
|
67
67
|
include ActiveAgent::Telemetry::Instrumentation
|
|
68
68
|
instrument_telemetry!
|
|
69
69
|
end
|
|
70
|
+
|
|
71
|
+
# Flush remaining traces when the process exits — without this,
|
|
72
|
+
# short-lived processes (rails runner, jobs, deploys rolling a
|
|
73
|
+
# server) drop whatever was buffered since the last interval flush.
|
|
74
|
+
at_exit { ActiveAgent::Telemetry.shutdown }
|
|
70
75
|
end
|
|
71
76
|
# endregion telemetry_configuration
|
|
72
77
|
|