activeagent 1.0.0 → 1.0.2

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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +10 -4
  4. data/lib/active_agent/base.rb +3 -2
  5. data/lib/active_agent/concerns/provider.rb +6 -2
  6. data/lib/active_agent/concerns/rescue.rb +39 -0
  7. data/lib/active_agent/concerns/streaming.rb +2 -1
  8. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +117 -0
  9. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +54 -0
  10. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +126 -0
  11. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +103 -0
  12. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +56 -0
  13. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +14 -0
  14. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +49 -0
  15. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +65 -0
  16. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +77 -0
  17. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +256 -0
  18. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +113 -0
  19. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +208 -0
  20. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +60 -0
  21. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +46 -0
  22. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +125 -0
  23. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +83 -0
  24. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +52 -0
  25. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +169 -0
  26. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +193 -0
  27. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +198 -0
  28. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +105 -0
  29. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +135 -0
  30. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +143 -0
  31. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +36 -0
  32. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +94 -0
  33. data/lib/active_agent/dashboard/config/routes.rb +78 -0
  34. data/lib/active_agent/dashboard/engine.rb +39 -0
  35. data/lib/active_agent/dashboard.rb +151 -0
  36. data/lib/active_agent/providers/_base_provider.rb +4 -1
  37. data/lib/active_agent/providers/anthropic/options.rb +4 -6
  38. data/lib/active_agent/providers/anthropic/request.rb +28 -3
  39. data/lib/active_agent/providers/anthropic/transforms.rb +131 -2
  40. data/lib/active_agent/providers/anthropic_provider.rb +97 -30
  41. data/lib/active_agent/providers/azure/_types.rb +5 -0
  42. data/lib/active_agent/providers/azure/options.rb +111 -0
  43. data/lib/active_agent/providers/azure_open_ai_provider.rb +2 -0
  44. data/lib/active_agent/providers/azure_openai_provider.rb +2 -0
  45. data/lib/active_agent/providers/azure_provider.rb +133 -0
  46. data/lib/active_agent/providers/azureopenai_provider.rb +2 -0
  47. data/lib/active_agent/providers/bedrock/_types.rb +8 -0
  48. data/lib/active_agent/providers/bedrock/bearer_client.rb +109 -0
  49. data/lib/active_agent/providers/bedrock/options.rb +77 -0
  50. data/lib/active_agent/providers/bedrock_provider.rb +84 -0
  51. data/lib/active_agent/providers/common/messages/_types.rb +42 -31
  52. data/lib/active_agent/providers/common/messages/assistant.rb +20 -4
  53. data/lib/active_agent/providers/concerns/exception_handler.rb +1 -0
  54. data/lib/active_agent/providers/concerns/previewable.rb +39 -5
  55. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +62 -0
  56. data/lib/active_agent/providers/gemini/_types.rb +19 -0
  57. data/lib/active_agent/providers/gemini/options.rb +41 -0
  58. data/lib/active_agent/providers/gemini_provider.rb +94 -0
  59. data/lib/active_agent/providers/open_ai/chat/transforms.rb +120 -4
  60. data/lib/active_agent/providers/open_ai/chat_provider.rb +40 -0
  61. data/lib/active_agent/providers/open_ai/responses/request.rb +17 -2
  62. data/lib/active_agent/providers/open_ai/responses/transforms.rb +135 -0
  63. data/lib/active_agent/providers/open_ai/responses_provider.rb +38 -0
  64. data/lib/active_agent/providers/open_router/request.rb +20 -0
  65. data/lib/active_agent/providers/open_router/transforms.rb +30 -0
  66. data/lib/active_agent/providers/open_router_provider.rb +14 -0
  67. data/lib/active_agent/providers/ruby_llm/_types.rb +77 -0
  68. data/lib/active_agent/providers/ruby_llm/embedding_request.rb +16 -0
  69. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +109 -0
  70. data/lib/active_agent/providers/ruby_llm/messages/assistant.rb +27 -0
  71. data/lib/active_agent/providers/ruby_llm/messages/base.rb +48 -0
  72. data/lib/active_agent/providers/ruby_llm/messages/system.rb +18 -0
  73. data/lib/active_agent/providers/ruby_llm/messages/tool.rb +24 -0
  74. data/lib/active_agent/providers/ruby_llm/messages/user.rb +18 -0
  75. data/lib/active_agent/providers/ruby_llm/options.rb +28 -0
  76. data/lib/active_agent/providers/ruby_llm/request.rb +30 -0
  77. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +45 -0
  78. data/lib/active_agent/providers/ruby_llm_provider.rb +407 -0
  79. data/lib/active_agent/railtie.rb +32 -1
  80. data/lib/active_agent/telemetry/configuration.rb +213 -0
  81. data/lib/active_agent/telemetry/instrumentation.rb +155 -0
  82. data/lib/active_agent/telemetry/reporter.rb +176 -0
  83. data/lib/active_agent/telemetry/span.rb +267 -0
  84. data/lib/active_agent/telemetry/tracer.rb +184 -0
  85. data/lib/active_agent/telemetry.rb +162 -0
  86. data/lib/active_agent/version.rb +1 -1
  87. data/lib/active_agent.rb +2 -0
  88. data/lib/generators/active_agent/dashboard/install/install_generator.rb +96 -0
  89. data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +89 -0
  90. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +42 -0
  91. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +38 -0
  92. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +22 -0
  93. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +53 -0
  94. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +28 -0
  95. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +43 -0
  96. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +44 -0
  97. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +56 -0
  98. data/lib/generators/active_agent/dashboard/install_generator.rb +64 -0
  99. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +30 -0
  100. data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +30 -0
  101. metadata +101 -14
@@ -4,11 +4,13 @@ require_relative "common/response"
4
4
  require_relative "concerns/exception_handler"
5
5
  require_relative "concerns/instrumentation"
6
6
  require_relative "concerns/previewable"
7
+ require_relative "concerns/tool_choice_clearing"
7
8
 
8
9
  # @private
9
10
  GEM_LOADERS = {
10
11
  anthropic: [ "anthropic", "~> 1.12", "anthropic" ],
11
- openai: [ "openai", "~> 0.34", "openai" ]
12
+ openai: [ "openai", "~> 0.34", "openai" ],
13
+ ruby_llm: [ "ruby_llm", ">= 1.0", "ruby_llm" ]
12
14
  }
13
15
 
14
16
  # Requires a provider's gem dependency.
@@ -45,6 +47,7 @@ module ActiveAgent
45
47
  include ExceptionHandler
46
48
  include Instrumentation
47
49
  include Previewable
50
+ include ToolChoiceClearing
48
51
 
49
52
  class ProvidersError < StandardError; end
50
53
 
@@ -28,15 +28,13 @@ module ActiveAgent
28
28
  end
29
29
 
30
30
  def serialize
31
- super.except(:anthropic_beta).tap do |hash|
32
- hash[:extra_headers] = extra_headers unless extra_headers.blank?
33
- end
31
+ super.except(:anthropic_beta)
34
32
  end
35
33
 
34
+ # Anthropic gem handles beta headers differently via client.beta
35
+ # rather than via extra_headers in request_options
36
36
  def extra_headers
37
- deep_compact(
38
- "anthropic-beta" => anthropic_beta.presence,
39
- )
37
+ {}
40
38
  end
41
39
 
42
40
  private
@@ -67,11 +67,13 @@ module ActiveAgent
67
67
  # @option params [Array<Hash>] :messages required
68
68
  # @option params [Integer] :max_tokens (4096)
69
69
  # @option params [Hash] :response_format custom field for JSON mode simulation
70
+ # @option params [String] :anthropic_beta beta version for features like MCP
70
71
  # @raise [ArgumentError] when gem model validation fails
71
72
  def initialize(**params)
72
73
  # Step 1: Extract custom fields that gem doesn't support
73
74
  @response_format = params.delete(:response_format)
74
75
  @stream = params.delete(:stream)
76
+ anthropic_beta = params.delete(:anthropic_beta)
75
77
 
76
78
  # Step 2: Map common format 'instructions' to Anthropic's 'system'
77
79
  if params.key?(:instructions)
@@ -84,10 +86,24 @@ module ActiveAgent
84
86
  # Step 4: Transform params for gem compatibility
85
87
  transformed = Transforms.normalize_params(params)
86
88
 
87
- # Step 5: Create gem model - this validates all parameters!
88
- gem_model = ::Anthropic::Models::MessageCreateParams.new(**transformed)
89
+ # Step 5: Determine if we need beta params (for MCP or other beta features)
90
+ use_beta = anthropic_beta.present? || transformed[:mcp_servers]&.any?
89
91
 
90
- # Step 6: Delegate all method calls to gem model
92
+ # Step 6: Add betas parameter if using beta API
93
+ if use_beta
94
+ # Default to MCP beta version if not specified
95
+ beta_version = anthropic_beta || "mcp-client-2025-04-04"
96
+ transformed[:betas] = [ beta_version ]
97
+ end
98
+
99
+ # Step 7: Create gem model - use Beta version if needed
100
+ gem_model = if use_beta
101
+ ::Anthropic::Models::Beta::MessageCreateParams.new(**transformed)
102
+ else
103
+ ::Anthropic::Models::MessageCreateParams.new(**transformed)
104
+ end
105
+
106
+ # Step 8: Delegate all method calls to gem model
91
107
  super(gem_model)
92
108
  rescue ArgumentError => e
93
109
  # Re-raise with more context
@@ -134,6 +150,15 @@ module ActiveAgent
134
150
  self.system = value
135
151
  end
136
152
 
153
+ # Accessor for MCP servers.
154
+ #
155
+ # Safely returns MCP servers array, defaulting to empty array if not set.
156
+ #
157
+ # @return [Array]
158
+ def mcp_servers
159
+ __getobj__.instance_variable_get(:@data)[:mcp_servers] || []
160
+ end
161
+
137
162
  # Removes the last message from the messages array.
138
163
  #
139
164
  # Used for JSON format simulation to remove the lead-in assistant message.
@@ -28,9 +28,137 @@ module ActiveAgent
28
28
  params = params.dup
29
29
  params[:messages] = normalize_messages(params[:messages]) if params[:messages]
30
30
  params[:system] = normalize_system(params[:system]) if params[:system]
31
+ params[:tools] = normalize_tools(params[:tools]) if params[:tools]
32
+ params[:tool_choice] = normalize_tool_choice(params[:tool_choice]) if params[:tool_choice]
33
+
34
+ # Handle mcps parameter (common format) -> transforms to mcp_servers (provider format)
35
+ if params[:mcps]
36
+ params[:mcp_servers] = normalize_mcp_servers(params.delete(:mcps))
37
+ elsif params[:mcp_servers]
38
+ params[:mcp_servers] = normalize_mcp_servers(params[:mcp_servers])
39
+ end
40
+
31
41
  params
32
42
  end
33
43
 
44
+ # Normalizes tools from common format to Anthropic format.
45
+ #
46
+ # Accepts both `parameters` and `input_schema` keys, converting to Anthropic's `input_schema`.
47
+ #
48
+ # @param tools [Array<Hash>]
49
+ # @return [Array<Hash>]
50
+ def normalize_tools(tools)
51
+ return tools unless tools.is_a?(Array)
52
+
53
+ tools.map do |tool|
54
+ tool_hash = tool.is_a?(Hash) ? tool.deep_symbolize_keys : tool
55
+
56
+ # If already in Anthropic format (has input_schema), return as-is
57
+ next tool_hash if tool_hash[:input_schema]
58
+
59
+ # Convert common format with 'parameters' to Anthropic format with 'input_schema'
60
+ if tool_hash[:parameters]
61
+ tool_hash = tool_hash.dup
62
+ tool_hash[:input_schema] = tool_hash.delete(:parameters)
63
+ end
64
+
65
+ tool_hash
66
+ end
67
+ end
68
+
69
+ # Normalizes MCP servers from common format to Anthropic format.
70
+ #
71
+ # Common format:
72
+ # {name: "stripe", url: "https://...", authorization: "token"}
73
+ # Anthropic format:
74
+ # {type: "url", name: "stripe", url: "https://...", authorization_token: "token"}
75
+ #
76
+ # @param mcp_servers [Array<Hash>]
77
+ # @return [Array<Hash>]
78
+ def normalize_mcp_servers(mcp_servers)
79
+ return mcp_servers unless mcp_servers.is_a?(Array)
80
+
81
+ mcp_servers.map do |server|
82
+ server_hash = server.is_a?(Hash) ? server.deep_symbolize_keys : server
83
+
84
+ # If already in Anthropic native format (has type: "url"), return as-is
85
+ # Check for absence of common format 'authorization' field OR presence of native 'authorization_token'
86
+ if server_hash[:type] == "url" && (server_hash[:authorization_token] || !server_hash[:authorization])
87
+ next server_hash
88
+ end
89
+
90
+ # Convert common format to Anthropic format
91
+ result = {
92
+ type: "url",
93
+ name: server_hash[:name],
94
+ url: server_hash[:url]
95
+ }
96
+
97
+ # Map 'authorization' to 'authorization_token'
98
+ if server_hash[:authorization]
99
+ result[:authorization_token] = server_hash[:authorization]
100
+ elsif server_hash[:authorization_token]
101
+ result[:authorization_token] = server_hash[:authorization_token]
102
+ end
103
+
104
+ result.compact
105
+ end
106
+ end
107
+
108
+ # Normalizes tool_choice from common format to Anthropic gem model objects.
109
+ #
110
+ # The Anthropic gem expects tool_choice to be a model object (ToolChoiceAuto,
111
+ # ToolChoiceAny, ToolChoiceTool, etc.), not a plain hash.
112
+ #
113
+ # Maps:
114
+ # - "required" → ToolChoiceAny (force tool use)
115
+ # - "auto" → ToolChoiceAuto (let model decide)
116
+ # - { name: "..." } → ToolChoiceTool with name
117
+ #
118
+ # @param tool_choice [String, Hash, Object]
119
+ # @return [Object] Anthropic gem model object
120
+ def normalize_tool_choice(tool_choice)
121
+ # If already a gem model object, return as-is
122
+ return tool_choice if tool_choice.is_a?(::Anthropic::Models::ToolChoiceAuto) ||
123
+ tool_choice.is_a?(::Anthropic::Models::ToolChoiceAny) ||
124
+ tool_choice.is_a?(::Anthropic::Models::ToolChoiceTool) ||
125
+ tool_choice.is_a?(::Anthropic::Models::ToolChoiceNone)
126
+
127
+ case tool_choice
128
+ when "required"
129
+ # Create ToolChoiceAny model for forcing tool use
130
+ ::Anthropic::Models::ToolChoiceAny.new(type: :any)
131
+ when "auto"
132
+ # Create ToolChoiceAuto model for letting model decide
133
+ ::Anthropic::Models::ToolChoiceAuto.new(type: :auto)
134
+ when Hash
135
+ choice_hash = tool_choice.deep_symbolize_keys
136
+
137
+ # If has type field, create appropriate model
138
+ if choice_hash[:type]
139
+ case choice_hash[:type].to_sym
140
+ when :any
141
+ ::Anthropic::Models::ToolChoiceAny.new(**choice_hash)
142
+ when :auto
143
+ ::Anthropic::Models::ToolChoiceAuto.new(**choice_hash)
144
+ when :tool
145
+ ::Anthropic::Models::ToolChoiceTool.new(**choice_hash)
146
+ when :none
147
+ ::Anthropic::Models::ToolChoiceNone.new(**choice_hash)
148
+ else
149
+ choice_hash
150
+ end
151
+ # Convert { name: "..." } to ToolChoiceTool
152
+ elsif choice_hash[:name]
153
+ ::Anthropic::Models::ToolChoiceTool.new(type: :tool, name: choice_hash[:name])
154
+ else
155
+ choice_hash
156
+ end
157
+ else
158
+ tool_choice
159
+ end
160
+ end
161
+
34
162
  # Merges consecutive same-role messages into single messages with multiple content blocks.
35
163
  #
36
164
  # Required by Anthropic API - consecutive messages with the same role must be combined.
@@ -317,9 +445,10 @@ module ActiveAgent
317
445
  # Apply content compression for API efficiency
318
446
  compress_content(hash)
319
447
 
320
- # Remove provider-internal fields that should not be in API request
321
- hash.delete(:mcp_servers) # Provider-level config, not API param
448
+ # Remove provider-internal fields and empty arrays
322
449
  hash.delete(:stop_sequences) if hash[:stop_sequences] == []
450
+ hash.delete(:mcp_servers) if hash[:mcp_servers] == []
451
+ hash.delete(:tool_choice) if hash[:tool_choice].nil? # Don't send null tool_choice
323
452
 
324
453
  # Remove default values (except max_tokens which is required by API)
325
454
  defaults.each do |key, value|
@@ -17,6 +17,17 @@ module ActiveAgent
17
17
  #
18
18
  # @see BaseProvider
19
19
  class AnthropicProvider < BaseProvider
20
+ # Lead-in message for JSON response format emulation
21
+ JSON_RESPONSE_FORMAT_LEAD_IN = "Here is the JSON requested:\n{"
22
+
23
+ attr_internal :json_format_retry_count
24
+
25
+ def initialize(kwargs = {})
26
+ super
27
+
28
+ self.json_format_retry_count = kwargs[:max_retries] || ::Anthropic::Client::DEFAULT_MAX_RETRIES
29
+ end
30
+
20
31
  # @todo Add support for Anthropic::BedrockClient and Anthropic::VertexClient
21
32
  # @return [Anthropic::Client]
22
33
  def client
@@ -39,22 +50,31 @@ module ActiveAgent
39
50
  super
40
51
  end
41
52
 
42
- # @api private
43
- def prepare_prompt_request_tools
44
- return unless request.tool_choice
45
- return unless request.tool_choice.respond_to?(:type)
53
+ # Extracts function names from Anthropic's tool_use content blocks.
54
+ #
55
+ # @return [Array<String>]
56
+ def extract_used_function_names
57
+ message_stack.pluck(:content).flatten.select { _1[:type] == "tool_use" }.pluck(:name)
58
+ end
46
59
 
47
- functions_used = message_stack.pluck(:content).flatten.select { _1[:type] == "tool_use" }.pluck(:name)
60
+ # Checks if tool_choice requires the model to call any tool.
61
+ #
62
+ # @return [Boolean] true if tool_choice type is :any
63
+ def tool_choice_forces_required?
64
+ return false unless request.tool_choice.respond_to?(:type)
48
65
 
49
- # tool_choice is always a gem model object (ToolChoiceAny, ToolChoiceTool, ToolChoiceAuto)
50
- tool_choice_type = request.tool_choice.type
51
- tool_choice_name = request.tool_choice.respond_to?(:name) ? request.tool_choice.name : nil
66
+ request.tool_choice.type == :any
67
+ end
52
68
 
53
- if (tool_choice_type == :any && functions_used.any?) ||
54
- (tool_choice_type == :tool && tool_choice_name && functions_used.include?(tool_choice_name))
69
+ # Checks if tool_choice requires a specific tool to be called.
70
+ #
71
+ # @return [Array<Boolean, String|nil>] [true, tool_name] if forcing a specific tool, [false, nil] otherwise
72
+ def tool_choice_forces_specific?
73
+ return [ false, nil ] unless request.tool_choice.respond_to?(:type)
74
+ return [ false, nil ] unless request.tool_choice.type == :tool
55
75
 
56
- request.tool_choice = nil
57
- end
76
+ tool_name = request.tool_choice.respond_to?(:name) ? request.tool_choice.name : nil
77
+ [ true, tool_name ]
58
78
  end
59
79
 
60
80
  # @api private
@@ -63,19 +83,30 @@ module ActiveAgent
63
83
 
64
84
  self.message_stack.push({
65
85
  role: "assistant",
66
- content: "Here is the JSON requested:\n{"
86
+ content: JSON_RESPONSE_FORMAT_LEAD_IN
67
87
  })
68
88
  end
69
89
 
90
+ # Selects between Anthropic's stable and beta message APIs.
91
+ #
92
+ # Uses beta API when explicitly requested via anthropic_beta option or when
93
+ # using MCP servers, which require beta features. Falls back to stable API
94
+ # for standard message creation.
95
+ #
70
96
  # @see BaseProvider#api_prompt_executer
71
- # @return [Anthropic::Messages]
97
+ # @return [Anthropic::Messages, Anthropic::Resources::Beta::Messages]
72
98
  def api_prompt_executer
73
- client.messages
99
+ # Use beta API when anthropic_beta option is set or when using MCP servers
100
+ if options.anthropic_beta.present? || request.mcp_servers&.any?
101
+ client.beta.messages
102
+ else
103
+ client.messages
104
+ end
74
105
  end
75
106
 
76
107
  # @see BaseProvider#api_response_normalize
77
108
  # @param api_response [Anthropic::Models::Message]
78
- # @return [Hash] normalized response hash
109
+ # @return [Hash]
79
110
  def api_response_normalize(api_response)
80
111
  return api_response unless api_response
81
112
 
@@ -158,9 +189,14 @@ module ActiveAgent
158
189
  when :ping
159
190
  # No-Op Keep Awake
160
191
  when :overloaded_error
161
- # TODO: https://docs.claude.com/en/docs/build-with-claude/streaming#error-events
192
+ # TODO: https://docs.claude.com/en/docs/build-with-claude/streaming#error-events
193
+
194
+ # Higher-level convenience events from anthropic gem's MessageStream
195
+ when :text, :input_json, :citation, :thinking, :signature
196
+ # No-Op; Already handled via :content_block_delta
197
+
162
198
  else
163
- # No-Op: Looks like internal tracking from gem wrapper
199
+ # No-Op; Internal tracking from gem wrapper
164
200
  return if api_response_chunk.respond_to?(:snapshot)
165
201
  raise "Unexpected chunk type: #{api_response_chunk.type}"
166
202
  end
@@ -200,31 +236,57 @@ module ActiveAgent
200
236
  end
201
237
  end
202
238
 
203
- # Converts API response message to hash for message_stack.
204
- # Converts Anthropic gem response object to hash for storage.
239
+ # Processes completed API response and handles JSON format retries.
240
+ #
241
+ # When response_format is json_object and the response fails JSON validation,
242
+ # recursively retries the request to obtain well-formed JSON.
205
243
  #
244
+ # @see BaseProvider#process_prompt_finished
206
245
  # @param api_response [Anthropic::Models::Message]
207
246
  # @return [Common::PromptResponse, nil]
208
247
  def process_prompt_finished(api_response = nil)
209
248
  # Convert gem object to hash so that raw_response[:usage] works
210
249
  api_response_hash = api_response ? Anthropic::Transforms.gem_to_hash(api_response) : nil
211
- super(api_response_hash)
250
+
251
+ common_response = super(api_response_hash)
252
+
253
+ # If we failed to get the expected well formed JSON Object Response, recursively try again
254
+ if request.response_format&.dig(:type) == "json_object" && common_response.message.parsed_json.nil? && json_format_retry_count > 0
255
+ self.json_format_retry_count -= 1
256
+
257
+ resolve_prompt
258
+ else
259
+ common_response
260
+ end
212
261
  end
213
262
 
263
+ # Reconstructs JSON responses that were split due to Anthropic format constraints.
214
264
  #
215
- # Handles JSON response format simulation by prepending `{` to the response
216
- # content after removing the assistant lead-in message.
265
+ # Anthropic's API doesn't natively support json_object response format, so we
266
+ # simulate it by having the assistant echo a JSON lead-in ("Here is the JSON requested:\n{"),
267
+ # then send the response back for completion. This method detects and reverses
268
+ # that workaround by stripping the lead-in message and prepending "{" to the response.
217
269
  #
218
270
  # @see BaseProvider#process_prompt_finished_extract_messages
219
- # @param api_response [Hash] converted response hash
271
+ # @param api_response [Hash] API response with content blocks
220
272
  # @return [Array<Hash>, nil]
221
273
  def process_prompt_finished_extract_messages(api_response)
222
274
  return unless api_response
223
275
 
224
- # Handle JSON response format simulation
225
- if request.response_format&.dig(:type) == "json_object"
226
- request.pop_message!
227
- api_response[:content][0][:text] = "{#{api_response[:content][0][:text]}"
276
+ # Get the last message (may be either Hash or gem object)
277
+ last_message = request.messages.last
278
+ last_role = last_message.is_a?(Hash) ? last_message[:role] : last_message&.role
279
+ last_content = last_message.is_a?(Hash) ? last_message[:content] : last_message&.content
280
+
281
+ # Check if the last message in request is the JSON lead-in prompt
282
+ if last_role.to_sym == :assistant && last_content == JSON_RESPONSE_FORMAT_LEAD_IN
283
+ # Remove the lead-in message from the request
284
+ request.messages.pop
285
+
286
+ # Prepend "{" to the response's first content text
287
+ if api_response[:content]&.first&.dig(:text)
288
+ api_response[:content][0][:text] = "{#{api_response[:content][0][:text]}"
289
+ end
228
290
  end
229
291
 
230
292
  [ api_response ]
@@ -239,11 +301,16 @@ module ActiveAgent
239
301
  def process_prompt_finished_extract_function_calls
240
302
  message_stack.pluck(:content).flatten.select { _1 in { type: "tool_use" } }.map do |api_function_call|
241
303
  json_buf = api_function_call.delete(:json_buf)
242
- api_function_call[:input] = JSON.parse(json_buf, symbolize_names: true) if json_buf
304
+ api_function_call[:input] = JSON.parse(json_buf, symbolize_names: true) if json_buf.present?
243
305
 
244
306
  # Handle case where :input is still a JSON string (gem >= 1.14.0)
307
+ # For tools with no parameters, input may be an empty string
245
308
  if api_function_call[:input].is_a?(String)
246
- api_function_call[:input] = JSON.parse(api_function_call[:input], symbolize_names: true)
309
+ if api_function_call[:input].present?
310
+ api_function_call[:input] = JSON.parse(api_function_call[:input], symbolize_names: true)
311
+ else
312
+ api_function_call[:input] = {}
313
+ end
247
314
  end
248
315
 
249
316
  api_function_call
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "options"
4
+ require_relative "../open_ai/chat/_types"
5
+ require_relative "../open_ai/embedding/_types"
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/options"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Azure
8
+ # Configuration options for Azure OpenAI Service.
9
+ #
10
+ # Azure OpenAI uses a different authentication and endpoint structure than standard OpenAI:
11
+ # - Endpoint: https://{resource}.openai.azure.com/openai/deployments/{deployment}/
12
+ # - Authentication: api-key header instead of Authorization: Bearer
13
+ # - API Version: Required query parameter
14
+ #
15
+ # You can configure Azure OpenAI in two ways:
16
+ #
17
+ # 1. Using azure_resource and deployment_id (standard Azure OpenAI):
18
+ # @example
19
+ # options = Azure::Options.new(
20
+ # api_key: ENV["AZURE_OPENAI_API_KEY"],
21
+ # azure_resource: "mycompany",
22
+ # deployment_id: "gpt-4-deployment",
23
+ # api_version: "2024-10-21"
24
+ # )
25
+ #
26
+ # 2. Using a direct host/base_url (for custom domains or Azure AI Foundry):
27
+ # @example
28
+ # options = Azure::Options.new(
29
+ # api_key: ENV["AZURE_OPENAI_API_KEY"],
30
+ # host: "https://mycompany.cognitiveservices.azure.com/openai/deployments/gpt-4",
31
+ # api_version: "2024-10-21"
32
+ # )
33
+ class Options < ActiveAgent::Providers::OpenAI::Options
34
+ DEFAULT_API_VERSION = "2024-10-21"
35
+
36
+ attribute :azure_resource, :string
37
+ attribute :deployment_id, :string
38
+ attribute :api_version, :string, fallback: DEFAULT_API_VERSION
39
+
40
+ validates :azure_resource, presence: true, unless: :explicit_host_provided?
41
+ validates :deployment_id, presence: true, unless: :explicit_host_provided?
42
+
43
+ def initialize(kwargs = {})
44
+ kwargs = kwargs.deep_symbolize_keys if kwargs.respond_to?(:deep_symbolize_keys)
45
+ kwargs[:api_version] ||= resolve_api_version(kwargs)
46
+ # Store explicit host before super processes kwargs
47
+ # host is aliased to base_url in parent, so check both
48
+ @explicit_host = kwargs[:host] || kwargs[:base_url]
49
+ super(kwargs)
50
+ end
51
+
52
+ # Returns Azure-specific headers for authentication.
53
+ #
54
+ # Azure uses api-key header instead of Authorization: Bearer.
55
+ #
56
+ # @return [Hash] headers including api-key
57
+ def extra_headers
58
+ { "api-key" => api_key }
59
+ end
60
+
61
+ # Returns Azure-specific query parameters.
62
+ #
63
+ # Azure requires api-version as a query parameter.
64
+ #
65
+ # @return [Hash] query parameters including api-version
66
+ def extra_query
67
+ { "api-version" => api_version }
68
+ end
69
+
70
+ # Builds the base URL for Azure OpenAI API requests.
71
+ #
72
+ # If a direct host/base_url is provided, uses that directly.
73
+ # Otherwise, constructs the URL from azure_resource and deployment_id.
74
+ #
75
+ # @return [String] the Azure OpenAI endpoint URL
76
+ def base_url
77
+ if @explicit_host.present?
78
+ @explicit_host
79
+ elsif azure_resource.present? && deployment_id.present?
80
+ "https://#{azure_resource}.openai.azure.com/openai/deployments/#{deployment_id}"
81
+ else
82
+ raise ArgumentError, "Either host or azure_resource + deployment_id must be provided"
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def explicit_host_provided?
89
+ @explicit_host.present?
90
+ end
91
+
92
+ def resolve_api_key(kwargs)
93
+ kwargs[:api_key] ||
94
+ kwargs[:access_token] ||
95
+ ENV["AZURE_OPENAI_API_KEY"] ||
96
+ ENV["AZURE_OPENAI_ACCESS_TOKEN"]
97
+ end
98
+
99
+ def resolve_api_version(kwargs)
100
+ kwargs[:api_version] ||
101
+ ENV["AZURE_OPENAI_API_VERSION"] ||
102
+ DEFAULT_API_VERSION
103
+ end
104
+
105
+ # Not used as part of Azure OpenAI
106
+ def resolve_organization_id(_settings) = nil
107
+ def resolve_project_id(_settings) = nil
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,2 @@
1
+ # Azure OpenAI, alias for AzureOpenAI service name resolution
2
+ require_relative "azure_provider"
@@ -0,0 +1,2 @@
1
+ # Azure OpenAI, alias for :azure_openai provider reference
2
+ require_relative "azure_provider"