activeagent 0.6.3 → 1.0.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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +240 -2
  3. data/README.md +15 -24
  4. data/lib/active_agent/base.rb +389 -39
  5. data/lib/active_agent/concerns/callbacks.rb +251 -0
  6. data/lib/active_agent/concerns/observers.rb +147 -0
  7. data/lib/active_agent/concerns/parameterized.rb +292 -0
  8. data/lib/active_agent/concerns/provider.rb +120 -0
  9. data/lib/active_agent/concerns/queueing.rb +36 -0
  10. data/lib/active_agent/concerns/rescue.rb +64 -0
  11. data/lib/active_agent/concerns/streaming.rb +282 -0
  12. data/lib/active_agent/concerns/tooling.rb +23 -0
  13. data/lib/active_agent/concerns/view.rb +150 -0
  14. data/lib/active_agent/configuration.rb +442 -20
  15. data/lib/active_agent/generation.rb +141 -47
  16. data/lib/active_agent/providers/_base_provider.rb +420 -0
  17. data/lib/active_agent/providers/anthropic/_types.rb +63 -0
  18. data/lib/active_agent/providers/anthropic/options.rb +53 -0
  19. data/lib/active_agent/providers/anthropic/request.rb +163 -0
  20. data/lib/active_agent/providers/anthropic/transforms.rb +353 -0
  21. data/lib/active_agent/providers/anthropic_provider.rb +254 -0
  22. data/lib/active_agent/providers/common/messages/_types.rb +160 -0
  23. data/lib/active_agent/providers/common/messages/assistant.rb +57 -0
  24. data/lib/active_agent/providers/common/messages/base.rb +17 -0
  25. data/lib/active_agent/providers/common/messages/system.rb +20 -0
  26. data/lib/active_agent/providers/common/messages/tool.rb +21 -0
  27. data/lib/active_agent/providers/common/messages/user.rb +20 -0
  28. data/lib/active_agent/providers/common/model.rb +361 -0
  29. data/lib/active_agent/providers/common/response.rb +13 -0
  30. data/lib/active_agent/providers/common/responses/_types.rb +51 -0
  31. data/lib/active_agent/providers/common/responses/base.rb +199 -0
  32. data/lib/active_agent/providers/common/responses/embed.rb +33 -0
  33. data/lib/active_agent/providers/common/responses/format.rb +31 -0
  34. data/lib/active_agent/providers/common/responses/message.rb +3 -0
  35. data/lib/active_agent/providers/common/responses/prompt.rb +42 -0
  36. data/lib/active_agent/providers/common/usage.rb +385 -0
  37. data/lib/active_agent/providers/concerns/exception_handler.rb +72 -0
  38. data/lib/active_agent/providers/concerns/instrumentation.rb +263 -0
  39. data/lib/active_agent/providers/concerns/previewable.rb +150 -0
  40. data/lib/active_agent/providers/log_subscriber.rb +178 -0
  41. data/lib/active_agent/providers/mock/_types.rb +77 -0
  42. data/lib/active_agent/providers/mock/embedding_request.rb +17 -0
  43. data/lib/active_agent/providers/mock/messages/_types.rb +103 -0
  44. data/lib/active_agent/providers/mock/messages/assistant.rb +26 -0
  45. data/lib/active_agent/providers/mock/messages/base.rb +63 -0
  46. data/lib/active_agent/providers/mock/messages/user.rb +18 -0
  47. data/lib/active_agent/providers/mock/options.rb +30 -0
  48. data/lib/active_agent/providers/mock/request.rb +38 -0
  49. data/lib/active_agent/providers/mock_provider.rb +311 -0
  50. data/lib/active_agent/providers/ollama/_types.rb +5 -0
  51. data/lib/active_agent/providers/ollama/chat/_types.rb +44 -0
  52. data/lib/active_agent/providers/ollama/chat/request.rb +249 -0
  53. data/lib/active_agent/providers/ollama/chat/transforms.rb +135 -0
  54. data/lib/active_agent/providers/ollama/embedding/_types.rb +44 -0
  55. data/lib/active_agent/providers/ollama/embedding/request.rb +190 -0
  56. data/lib/active_agent/providers/ollama/embedding/transforms.rb +160 -0
  57. data/lib/active_agent/providers/ollama/options.rb +27 -0
  58. data/lib/active_agent/providers/ollama_provider.rb +94 -0
  59. data/lib/active_agent/providers/open_ai/_base.rb +59 -0
  60. data/lib/active_agent/providers/open_ai/_types.rb +5 -0
  61. data/lib/active_agent/providers/open_ai/chat/_types.rb +56 -0
  62. data/lib/active_agent/providers/open_ai/chat/request.rb +161 -0
  63. data/lib/active_agent/providers/open_ai/chat/transforms.rb +364 -0
  64. data/lib/active_agent/providers/open_ai/chat_provider.rb +219 -0
  65. data/lib/active_agent/providers/open_ai/embedding/_types.rb +56 -0
  66. data/lib/active_agent/providers/open_ai/embedding/request.rb +53 -0
  67. data/lib/active_agent/providers/open_ai/embedding/transforms.rb +88 -0
  68. data/lib/active_agent/providers/open_ai/options.rb +74 -0
  69. data/lib/active_agent/providers/open_ai/responses/_types.rb +44 -0
  70. data/lib/active_agent/providers/open_ai/responses/request.rb +129 -0
  71. data/lib/active_agent/providers/open_ai/responses/transforms.rb +228 -0
  72. data/lib/active_agent/providers/open_ai/responses_provider.rb +200 -0
  73. data/lib/active_agent/providers/open_ai_provider.rb +94 -0
  74. data/lib/active_agent/providers/open_router/_types.rb +71 -0
  75. data/lib/active_agent/providers/open_router/options.rb +141 -0
  76. data/lib/active_agent/providers/open_router/request.rb +249 -0
  77. data/lib/active_agent/providers/open_router/requests/_types.rb +197 -0
  78. data/lib/active_agent/providers/open_router/requests/messages/_types.rb +56 -0
  79. data/lib/active_agent/providers/open_router/requests/messages/content/_types.rb +97 -0
  80. data/lib/active_agent/providers/open_router/requests/messages/content/file.rb +43 -0
  81. data/lib/active_agent/providers/open_router/requests/messages/content/files/_types.rb +61 -0
  82. data/lib/active_agent/providers/open_router/requests/messages/content/files/details.rb +37 -0
  83. data/lib/active_agent/providers/open_router/requests/plugin.rb +41 -0
  84. data/lib/active_agent/providers/open_router/requests/plugins/_types.rb +46 -0
  85. data/lib/active_agent/providers/open_router/requests/plugins/pdf_config.rb +51 -0
  86. data/lib/active_agent/providers/open_router/requests/prediction.rb +34 -0
  87. data/lib/active_agent/providers/open_router/requests/provider_preferences/_types.rb +44 -0
  88. data/lib/active_agent/providers/open_router/requests/provider_preferences/max_price.rb +64 -0
  89. data/lib/active_agent/providers/open_router/requests/provider_preferences.rb +105 -0
  90. data/lib/active_agent/providers/open_router/requests/response_format.rb +77 -0
  91. data/lib/active_agent/providers/open_router/transforms.rb +134 -0
  92. data/lib/active_agent/providers/open_router_provider.rb +62 -0
  93. data/lib/active_agent/providers/openai_provider.rb +2 -0
  94. data/lib/active_agent/providers/openrouter_provider.rb +2 -0
  95. data/lib/active_agent/railtie.rb +8 -6
  96. data/lib/active_agent/schema_generator.rb +333 -166
  97. data/lib/active_agent/version.rb +1 -1
  98. data/lib/active_agent.rb +112 -36
  99. data/lib/generators/active_agent/agent/USAGE +78 -0
  100. data/lib/generators/active_agent/{agent_generator.rb → agent/agent_generator.rb} +14 -4
  101. data/lib/generators/active_agent/install/USAGE +25 -0
  102. data/lib/generators/active_agent/{install_generator.rb → install/install_generator.rb} +1 -19
  103. data/lib/generators/active_agent/templates/agent.rb.tt +7 -3
  104. data/lib/generators/active_agent/templates/application_agent.rb.tt +0 -2
  105. data/lib/generators/erb/agent_generator.rb +31 -16
  106. data/lib/generators/erb/templates/instructions.md.erb.tt +3 -0
  107. data/lib/generators/erb/templates/instructions.md.tt +3 -0
  108. data/lib/generators/erb/templates/instructions.text.tt +1 -0
  109. data/lib/generators/erb/templates/message.md.erb.tt +5 -0
  110. data/lib/generators/erb/templates/schema.json.tt +10 -0
  111. data/lib/generators/test_unit/agent_generator.rb +1 -1
  112. data/lib/generators/test_unit/templates/functional_test.rb.tt +4 -2
  113. metadata +182 -71
  114. data/lib/active_agent/action_prompt/action.rb +0 -13
  115. data/lib/active_agent/action_prompt/base.rb +0 -623
  116. data/lib/active_agent/action_prompt/message.rb +0 -126
  117. data/lib/active_agent/action_prompt/prompt.rb +0 -136
  118. data/lib/active_agent/action_prompt.rb +0 -19
  119. data/lib/active_agent/callbacks.rb +0 -33
  120. data/lib/active_agent/generation_provider/anthropic_provider.rb +0 -163
  121. data/lib/active_agent/generation_provider/base.rb +0 -55
  122. data/lib/active_agent/generation_provider/base_adapter.rb +0 -19
  123. data/lib/active_agent/generation_provider/error_handling.rb +0 -167
  124. data/lib/active_agent/generation_provider/log_subscriber.rb +0 -92
  125. data/lib/active_agent/generation_provider/message_formatting.rb +0 -107
  126. data/lib/active_agent/generation_provider/ollama_provider.rb +0 -66
  127. data/lib/active_agent/generation_provider/open_ai_provider.rb +0 -279
  128. data/lib/active_agent/generation_provider/open_router_provider.rb +0 -385
  129. data/lib/active_agent/generation_provider/parameter_builder.rb +0 -119
  130. data/lib/active_agent/generation_provider/response.rb +0 -75
  131. data/lib/active_agent/generation_provider/responses_adapter.rb +0 -44
  132. data/lib/active_agent/generation_provider/stream_processing.rb +0 -58
  133. data/lib/active_agent/generation_provider/tool_management.rb +0 -142
  134. data/lib/active_agent/generation_provider.rb +0 -67
  135. data/lib/active_agent/log_subscriber.rb +0 -44
  136. data/lib/active_agent/parameterized.rb +0 -75
  137. data/lib/active_agent/prompt_helper.rb +0 -19
  138. data/lib/active_agent/queued_generation.rb +0 -12
  139. data/lib/active_agent/rescuable.rb +0 -34
  140. data/lib/active_agent/sanitizers.rb +0 -40
  141. data/lib/active_agent/streaming.rb +0 -34
  142. data/lib/active_agent/test_case.rb +0 -125
  143. data/lib/generators/USAGE +0 -47
  144. data/lib/generators/active_agent/USAGE +0 -56
  145. data/lib/generators/erb/install_generator.rb +0 -44
  146. data/lib/generators/erb/templates/layout.html.erb.tt +0 -1
  147. data/lib/generators/erb/templates/layout.json.erb.tt +0 -1
  148. data/lib/generators/erb/templates/layout.text.erb.tt +0 -1
  149. data/lib/generators/erb/templates/view.html.erb.tt +0 -5
  150. data/lib/generators/erb/templates/view.json.erb.tt +0 -16
  151. /data/lib/active_agent/{preview.rb → concerns/preview.rb} +0 -0
  152. /data/lib/generators/erb/templates/{view.text.erb.tt → message.text.erb.tt} +0 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "options"
4
+ require_relative "request"
5
+
6
+ module ActiveAgent
7
+ module Providers
8
+ module Anthropic
9
+ # ActiveModel type for casting and serializing Anthropic Request objects.
10
+ #
11
+ # Handles conversion between Hash, Request, and serialized formats for API calls.
12
+ # The Request class now delegates to the official Anthropic gem model, eliminating
13
+ # the need for maintaining nested type definitions.
14
+ class RequestType < ActiveModel::Type::Value
15
+ # Casts input to Request object.
16
+ #
17
+ # @param value [Request, Hash, nil]
18
+ # @return [Request, nil]
19
+ # @raise [ArgumentError] when value cannot be cast to Request
20
+ def cast(value)
21
+ case value
22
+ when Request
23
+ value
24
+ when Hash
25
+ Request.new(**value.deep_symbolize_keys)
26
+ when nil
27
+ nil
28
+ else
29
+ raise ArgumentError, "Cannot cast #{value.class} to Request"
30
+ end
31
+ end
32
+
33
+ # Serializes Request to Hash for API submission.
34
+ #
35
+ # Removes `:response_format` key as it's a simulated feature not directly
36
+ # supported by Anthropic's API.
37
+ #
38
+ # @param value [Request, Hash, nil]
39
+ # @return [Hash, nil]
40
+ # @raise [ArgumentError] when value cannot be serialized
41
+ def serialize(value)
42
+ case value
43
+ when Request
44
+ # Response Format is a simulated feature, not directly supported by API
45
+ value.serialize.except(:response_format)
46
+ when Hash
47
+ value
48
+ when nil
49
+ nil
50
+ else
51
+ raise ArgumentError, "Cannot serialize #{value.class}"
52
+ end
53
+ end
54
+
55
+ # @param value [Object]
56
+ # @return [Request, nil]
57
+ def deserialize(value)
58
+ cast(value)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_agent/providers/common/model"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Anthropic
8
+ class Options < Common::BaseModel
9
+ attribute :api_key, :string
10
+ attribute :base_url, :string, default: "https://api.anthropic.com"
11
+
12
+ attribute :anthropic_beta, :string
13
+
14
+ attribute :max_retries, :integer, default: ::Anthropic::Client::DEFAULT_MAX_RETRIES
15
+ attribute :timeout, :float, default: ::Anthropic::Client::DEFAULT_TIMEOUT_IN_SECONDS
16
+ attribute :initial_retry_delay, :float, default: ::Anthropic::Client::DEFAULT_INITIAL_RETRY_DELAY
17
+ attribute :max_retry_delay, :float, default: ::Anthropic::Client::DEFAULT_MAX_RETRY_DELAY
18
+
19
+ # Common Interface Compatibility
20
+ alias_attribute :access_token, :api_key
21
+
22
+ def initialize(kwargs = {})
23
+ kwargs = kwargs.deep_symbolize_keys if kwargs.respond_to?(:deep_symbolize_keys)
24
+
25
+ super(**deep_compact(kwargs.except(:default_url_options).merge(
26
+ api_key: kwargs[:api_key] || resolve_access_token(kwargs),
27
+ )))
28
+ end
29
+
30
+ def serialize
31
+ super.except(:anthropic_beta).tap do |hash|
32
+ hash[:extra_headers] = extra_headers unless extra_headers.blank?
33
+ end
34
+ end
35
+
36
+ def extra_headers
37
+ deep_compact(
38
+ "anthropic-beta" => anthropic_beta.presence,
39
+ )
40
+ end
41
+
42
+ private
43
+
44
+ def resolve_access_token(kwargs)
45
+ kwargs[:api_key] ||
46
+ kwargs[:access_token] ||
47
+ ENV["ANTHROPIC_ACCESS_TOKEN"] ||
48
+ ENV["ANTHROPIC_API_KEY"]
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require "json"
5
+ require_relative "transforms"
6
+
7
+ module ActiveAgent
8
+ module Providers
9
+ module Anthropic
10
+ # Request wrapper that delegates to Anthropic gem model.
11
+ #
12
+ # Uses SimpleDelegator to wrap ::Anthropic::Models::MessageCreateParams,
13
+ # eliminating the need to maintain duplicate attribute definitions while
14
+ # providing convenience transformations and custom fields.
15
+ #
16
+ # All standard Anthropic API fields are automatically available via delegation:
17
+ # - model, messages, max_tokens
18
+ # - system, temperature, top_k, top_p, stop_sequences
19
+ # - tools, tool_choice, thinking
20
+ # - stream, metadata, context_management, container, service_tier, mcp_servers
21
+ #
22
+ # Custom fields managed separately:
23
+ # - response_format (simulated JSON mode feature)
24
+ #
25
+ # @example Basic usage
26
+ # request = Request.new(
27
+ # model: "claude-3-5-haiku-latest",
28
+ # messages: [{role: "user", content: "Hello"}]
29
+ # )
30
+ # request.model #=> "claude-3-5-haiku-latest"
31
+ # request.max_tokens #=> 4096 (default)
32
+ #
33
+ # @example With transformations
34
+ # # String content is automatically normalized
35
+ # request = Request.new(
36
+ # model: "...",
37
+ # messages: [{role: "user", content: "Hi"}]
38
+ # )
39
+ # # Internally becomes: [{type: "text", text: "Hi"}]
40
+ #
41
+ # @example Custom field
42
+ # request = Request.new(
43
+ # model: "...",
44
+ # messages: [...],
45
+ # response_format: {type: "json_object"}
46
+ # )
47
+ # request.response_format #=> {type: "json_object"}
48
+ class Request < SimpleDelegator
49
+ # Default max_tokens value when not specified
50
+ DEFAULT_MAX_TOKENS = 4096
51
+
52
+ # Default values for optional parameters
53
+ DEFAULTS = {
54
+ max_tokens: DEFAULT_MAX_TOKENS,
55
+ stop_sequences: [],
56
+ mcp_servers: []
57
+ }.freeze
58
+
59
+ # @return [Hash, nil] simulated JSON response format configuration
60
+ attr_reader :response_format
61
+
62
+ # @return [Boolean, nil] whether to stream the response
63
+ attr_reader :stream
64
+
65
+ # @param params [Hash]
66
+ # @option params [String] :model required
67
+ # @option params [Array<Hash>] :messages required
68
+ # @option params [Integer] :max_tokens (4096)
69
+ # @option params [Hash] :response_format custom field for JSON mode simulation
70
+ # @raise [ArgumentError] when gem model validation fails
71
+ def initialize(**params)
72
+ # Step 1: Extract custom fields that gem doesn't support
73
+ @response_format = params.delete(:response_format)
74
+ @stream = params.delete(:stream)
75
+
76
+ # Step 2: Map common format 'instructions' to Anthropic's 'system'
77
+ if params.key?(:instructions)
78
+ params[:system] = params.delete(:instructions)
79
+ end
80
+
81
+ # Step 3: Apply defaults
82
+ params = apply_defaults(params)
83
+
84
+ # Step 4: Transform params for gem compatibility
85
+ transformed = Transforms.normalize_params(params)
86
+
87
+ # Step 5: Create gem model - this validates all parameters!
88
+ gem_model = ::Anthropic::Models::MessageCreateParams.new(**transformed)
89
+
90
+ # Step 6: Delegate all method calls to gem model
91
+ super(gem_model)
92
+ rescue ArgumentError => e
93
+ # Re-raise with more context
94
+ raise ArgumentError, "Invalid Anthropic request parameters: #{e.message}"
95
+ end
96
+
97
+ # Serializes request for API call.
98
+ #
99
+ # Uses gem's JSON serialization and delegates cleanup to Transforms module.
100
+ #
101
+ # @return [Hash]
102
+ def serialize
103
+ # Use gem's JSON serialization (handles all nested objects)
104
+ hash = Anthropic::Transforms.gem_to_hash(__getobj__)
105
+
106
+ # Delegate cleanup to transforms module
107
+ Transforms.cleanup_serialized_request(hash, DEFAULTS, __getobj__)
108
+ end
109
+
110
+ # Accessor for system instructions.
111
+ #
112
+ # Must override SimpleDelegator's method_missing because Ruby's Kernel.system
113
+ # conflicts with delegation. The gem stores data in @data instance variable.
114
+ #
115
+ # @return [String, Array, nil]
116
+ def system
117
+ __getobj__.instance_variable_get(:@data)[:system]
118
+ end
119
+
120
+ # @param value [String, Array]
121
+ def system=(value)
122
+ __getobj__.instance_variable_get(:@data)[:system] = value
123
+ end
124
+
125
+ # Alias for system (common format compatibility).
126
+ #
127
+ # @return [String, Array, nil]
128
+ def instructions
129
+ system
130
+ end
131
+
132
+ # @param value [String, Array]
133
+ def instructions=(value)
134
+ self.system = value
135
+ end
136
+
137
+ # Removes the last message from the messages array.
138
+ #
139
+ # Used for JSON format simulation to remove the lead-in assistant message.
140
+ #
141
+ # @return [void]
142
+ def pop_message!
143
+ new_messages = messages.dup
144
+ new_messages.pop
145
+ self.messages = new_messages
146
+ end
147
+
148
+ private
149
+
150
+ # @param params [Hash]
151
+ # @return [Hash]
152
+ def apply_defaults(params)
153
+ # Only apply defaults for keys that aren't present
154
+ DEFAULTS.each do |key, value|
155
+ params[key] = value unless params.key?(key)
156
+ end
157
+
158
+ params
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/keys"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Anthropic
8
+ # Transforms between convenient input formats and Anthropic API structures.
9
+ #
10
+ # Provides bidirectional transformations:
11
+ # - Expand: shortcuts → API format (string → content blocks, consecutive messages → grouped)
12
+ # - Compress: API format → shortcuts (single content blocks → strings for efficiency)
13
+ module Transforms
14
+ class << self
15
+ # Converts gem model object to hash via JSON round-trip.
16
+ #
17
+ # This ensures proper nested serialization and symbolic keys.
18
+ #
19
+ # @param gem_object [Object] any object responding to .to_json
20
+ # @return [Hash]
21
+ def gem_to_hash(gem_object)
22
+ JSON.parse(gem_object.to_json, symbolize_names: true)
23
+ end
24
+
25
+ # @param params [Hash]
26
+ # @return [Hash]
27
+ def normalize_params(params)
28
+ params = params.dup
29
+ params[:messages] = normalize_messages(params[:messages]) if params[:messages]
30
+ params[:system] = normalize_system(params[:system]) if params[:system]
31
+ params
32
+ end
33
+
34
+ # Merges consecutive same-role messages into single messages with multiple content blocks.
35
+ #
36
+ # Required by Anthropic API - consecutive messages with the same role must be combined.
37
+ #
38
+ # @param messages [Array<Hash>]
39
+ # @return [Array<Hash>]
40
+ def normalize_messages(messages)
41
+ return messages unless messages.is_a?(Array)
42
+
43
+ grouped = []
44
+
45
+ messages.each do |msg|
46
+ msg_hash = msg.is_a?(Hash) ? msg.deep_symbolize_keys : { role: :user, content: msg }
47
+
48
+ # Extract role
49
+ role = msg_hash[:role]&.to_sym || :user
50
+
51
+ # Determine content
52
+ if msg_hash.key?(:content)
53
+ # Has explicit content key
54
+ content = normalize_content(msg_hash[:content])
55
+ elsif msg_hash.key?(:role) && msg_hash.keys.size > 1
56
+ # Has role + other keys (e.g., {role: "assistant", text: "..."})
57
+ # Treat everything except :role as content
58
+ content = normalize_content(msg_hash.except(:role))
59
+ elsif !msg_hash.key?(:role)
60
+ # No role or content - treat entire hash as content
61
+ content = normalize_content(msg_hash)
62
+ else
63
+ # Only has role, no content
64
+ content = []
65
+ end
66
+
67
+ if grouped.empty? || grouped.last[:role] != role
68
+ grouped << { role: role, content: content }
69
+ else
70
+ # Merge content from consecutive same-role messages
71
+ grouped.last[:content] += content
72
+ end
73
+ end
74
+
75
+ grouped
76
+ end
77
+
78
+ # Converts system content shortcuts to API format.
79
+ #
80
+ # Handles string, hash, or array inputs. Strings pass through unchanged
81
+ # since Anthropic accepts both string and structured formats.
82
+ #
83
+ # @param system [String, Array, Hash]
84
+ # @return [String, Array]
85
+ def normalize_system(system)
86
+ case system
87
+ when String
88
+ # Keep strings as-is - Anthropic accepts both string and array
89
+ system
90
+ when Array
91
+ # Normalize array of system blocks
92
+ system.map { |block| normalize_system_block(block) }
93
+ when Hash
94
+ # Single hash becomes array with one block
95
+ [ normalize_system_block(system) ]
96
+ else
97
+ system
98
+ end
99
+ end
100
+
101
+ # @param block [String, Hash]
102
+ # @return [Hash]
103
+ def normalize_system_block(block)
104
+ case block
105
+ when String
106
+ { type: "text", text: block }
107
+ when Hash
108
+ hash = block.deep_symbolize_keys
109
+ # Add type if missing and can be inferred
110
+ hash[:type] ||= "text" if hash[:text]
111
+ hash
112
+ else
113
+ block
114
+ end
115
+ end
116
+
117
+ # Expands content shortcuts into structured content block arrays.
118
+ #
119
+ # Handles multiple input formats:
120
+ # - String → `[{type: "text", text: "..."}]`
121
+ # - Hash with multiple keys → separate blocks per content type
122
+ # - Array → normalized items
123
+ #
124
+ # @param content [String, Array, Hash]
125
+ # @return [Array<Hash>]
126
+ def normalize_content(content)
127
+ case content
128
+ when String
129
+ # String → array with single text block
130
+ [ { type: "text", text: content } ]
131
+ when Array
132
+ # Normalize each item in the array
133
+ content.flat_map { |item| normalize_content_item(item) }
134
+ when Hash
135
+ # Check if hash has multiple content keys (text, image, document)
136
+ # If so, expand into separate content blocks
137
+ hash = content.deep_symbolize_keys
138
+ content_keys = [ :text, :image, :document ]
139
+ found_keys = content_keys & hash.keys
140
+
141
+ if found_keys.size > 1
142
+ # Multiple content types - expand into array
143
+ found_keys.flat_map { |key| normalize_content_item({ key => hash[key] }) }
144
+ else
145
+ # Single content item
146
+ [ normalize_content_item(content) ]
147
+ end
148
+ when nil
149
+ []
150
+ else
151
+ # Pass through other types (might be gem objects already)
152
+ [ content ]
153
+ end
154
+ end
155
+
156
+ # Infers content block type from hash keys or converts string to text block.
157
+ #
158
+ # @param item [String, Hash]
159
+ # @return [Hash]
160
+ def normalize_content_item(item)
161
+ case item
162
+ when String
163
+ { type: "text", text: item }
164
+ when Hash
165
+ hash = item.deep_symbolize_keys
166
+
167
+ # If type is specified, return as-is
168
+ return hash if hash[:type]
169
+
170
+ # Type inference based on keys
171
+ if hash[:text]
172
+ { type: "text" }.merge(hash)
173
+ elsif hash[:image]
174
+ # Normalize image source format
175
+ source = normalize_source(hash[:image])
176
+ { type: "image", source: source }.merge(hash.except(:image))
177
+ elsif hash[:document]
178
+ # Normalize document source format
179
+ source = normalize_source(hash[:document])
180
+ { type: "document", source: source }.merge(hash.except(:document))
181
+ elsif hash[:tool_use_id]
182
+ # Tool result content
183
+ { type: "tool_result" }.merge(hash)
184
+ elsif hash[:id] && hash[:name] && hash[:input]
185
+ # Tool use content
186
+ { type: "tool_use" }.merge(hash)
187
+ else
188
+ # Unknown format - return as-is and let gem validate
189
+ hash
190
+ end
191
+ else
192
+ # Pass through (might be gem object)
193
+ item
194
+ end
195
+ end
196
+
197
+ # Converts image/document source shortcuts to API structure.
198
+ #
199
+ # Handles multiple formats:
200
+ # - Regular URL → `{type: "url", url: "..."}`
201
+ # - Data URI → `{type: "base64", media_type: "...", data: "..."}`
202
+ # - Hash with base64 → `{type: "base64", media_type: "...", data: "..."}`
203
+ #
204
+ # @param source [String, Hash]
205
+ # @return [Hash]
206
+ def normalize_source(source)
207
+ case source
208
+ when String
209
+ # Check if it's a data URI (e.g., "data:image/png;base64,...")
210
+ if source.start_with?("data:")
211
+ parse_data_uri(source)
212
+ else
213
+ # Regular URL → wrap in url source type
214
+ { type: "url", url: source }
215
+ end
216
+ when Hash
217
+ hash = source.deep_symbolize_keys
218
+ # Already has type → return as-is
219
+ return hash if hash[:type]
220
+
221
+ # Has base64 data → add type
222
+ if hash[:data] && hash[:media_type]
223
+ { type: "base64" }.merge(hash)
224
+ else
225
+ # Unknown format → return as-is
226
+ hash
227
+ end
228
+ else
229
+ source
230
+ end
231
+ end
232
+
233
+ # Extracts media type and data from data URI.
234
+ #
235
+ # Expected format: `data:[<media type>][;base64],<data>`
236
+ #
237
+ # @param data_uri [String] e.g., "data:image/png;base64,iVBORw0..."
238
+ # @return [Hash] `{type: "base64", media_type: "...", data: "..."}`
239
+ def parse_data_uri(data_uri)
240
+ # Extract media type and data from data URI
241
+ # Format: data:[<media type>][;base64],<data>
242
+ match = data_uri.match(%r{\Adata:([^;,]+)(?:;base64)?,(.+)\z})
243
+
244
+ if match
245
+ {
246
+ type: "base64",
247
+ media_type: match[1],
248
+ data: match[2]
249
+ }
250
+ else
251
+ # Invalid data URI - return as URL fallback
252
+ { type: "url", url: data_uri }
253
+ end
254
+ end
255
+
256
+ # Converts single-element content arrays back to string shorthand.
257
+ #
258
+ # Reduces payload size by reversing the expansion done by normalize methods.
259
+ #
260
+ # @param hash [Hash]
261
+ # @return [Hash]
262
+ def compress_content(hash)
263
+ return hash unless hash.is_a?(Hash)
264
+
265
+ # Compress message content
266
+ hash[:messages]&.each do |msg|
267
+ compress_message_content!(msg)
268
+ end
269
+
270
+ # Compress system content
271
+ if hash[:system].is_a?(Array)
272
+ hash[:system] = compress_system_content(hash[:system])
273
+ end
274
+
275
+ hash
276
+ end
277
+
278
+ # Converts single text block arrays to string shorthand.
279
+ #
280
+ # `[{type: "text", text: "hello"}]` → `"hello"`
281
+ #
282
+ # @param msg [Hash] message with :content key
283
+ # @return [void]
284
+ def compress_message_content!(msg)
285
+ content = msg[:content]
286
+ return unless content.is_a?(Array)
287
+
288
+ # Single text block → string shorthand
289
+ if content.one? && content.first.is_a?(Hash) && content.first[:type] == "text"
290
+ msg[:content] = content.first[:text]
291
+ end
292
+ end
293
+
294
+ # Cleans up serialized request for API submission.
295
+ #
296
+ # Removes response-only fields, applies content compression,
297
+ # removes provider-internal fields, and removes default values.
298
+ # Note: max_tokens is kept even if it matches default as Anthropic API requires it.
299
+ #
300
+ # @param hash [Hash] serialized request
301
+ # @param defaults [Hash] default values to remove
302
+ # @param gem_object [Object] original gem object (unused but for consistency)
303
+ # @return [Hash] cleaned request hash
304
+ def cleanup_serialized_request(hash, defaults, gem_object = nil)
305
+ # Remove response-only fields from messages
306
+ if hash[:messages]
307
+ hash[:messages].each do |msg|
308
+ msg.delete(:id)
309
+ msg.delete(:model)
310
+ msg.delete(:stop_reason)
311
+ msg.delete(:stop_sequence)
312
+ msg.delete(:type)
313
+ msg.delete(:usage)
314
+ end
315
+ end
316
+
317
+ # Apply content compression for API efficiency
318
+ compress_content(hash)
319
+
320
+ # Remove provider-internal fields that should not be in API request
321
+ hash.delete(:mcp_servers) # Provider-level config, not API param
322
+ hash.delete(:stop_sequences) if hash[:stop_sequences] == []
323
+
324
+ # Remove default values (except max_tokens which is required by API)
325
+ defaults.each do |key, value|
326
+ next if key == :max_tokens # Anthropic API requires max_tokens
327
+ hash.delete(key) if hash[key] == value
328
+ end
329
+
330
+ hash
331
+ end
332
+
333
+ private
334
+
335
+ # Converts single text block to string.
336
+ #
337
+ # @param system [Array]
338
+ # @return [String, Array]
339
+ def compress_system_content(system)
340
+ return system unless system.is_a?(Array)
341
+
342
+ # Single text block → string shorthand
343
+ if system.one? && system.first.is_a?(Hash) && system.first[:type] == "text"
344
+ system.first[:text]
345
+ else
346
+ system
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end