little_ghost 0.1.0 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -47
  3. data/docs/guides/{Core Concepts.md → core_concepts.md} +40 -20
  4. data/docs/guides/getting_started.md +164 -0
  5. data/lib/little_ghost/ag_ui/adapter.rb +1 -1
  6. data/lib/little_ghost/agent/context_management.rb +1 -4
  7. data/lib/little_ghost/agent.rb +94 -26
  8. data/lib/little_ghost/agent_builder.rb +1 -1
  9. data/lib/little_ghost/configuration.rb +210 -29
  10. data/lib/little_ghost/data/model_catalog.json +19178 -0
  11. data/lib/little_ghost/errors.rb +6 -0
  12. data/lib/little_ghost/invocation.rb +2 -17
  13. data/lib/little_ghost/model.rb +53 -33
  14. data/lib/little_ghost/model_capabilities.rb +6 -5
  15. data/lib/little_ghost/model_resolver.rb +316 -0
  16. data/lib/little_ghost/models/catalog/models_dev_source.rb +62 -0
  17. data/lib/little_ghost/models/catalog/source.rb +30 -0
  18. data/lib/little_ghost/models/catalog.rb +154 -0
  19. data/lib/little_ghost/models/catalog_snapshot.rb +44 -0
  20. data/lib/little_ghost/models/configuration.rb +43 -0
  21. data/lib/little_ghost/models/details.rb +48 -0
  22. data/lib/little_ghost/models/target.rb +29 -0
  23. data/lib/little_ghost/provider_registry.rb +75 -0
  24. data/lib/little_ghost/providers/anthropic/catalog_source.rb +31 -0
  25. data/lib/little_ghost/providers/anthropic.rb +222 -0
  26. data/lib/little_ghost/providers/base.rb +46 -0
  27. data/lib/little_ghost/providers/bedrock/aws_protocol.rb +132 -0
  28. data/lib/little_ghost/providers/bedrock/catalog_source.rb +200 -0
  29. data/lib/little_ghost/providers/bedrock/credential_resolver.rb +123 -0
  30. data/lib/little_ghost/providers/bedrock/http_client.rb +78 -0
  31. data/lib/little_ghost/providers/bedrock.rb +23 -15
  32. data/lib/little_ghost/providers/configuration.rb +79 -0
  33. data/lib/little_ghost/providers/gemini/catalog_source.rb +35 -0
  34. data/lib/little_ghost/providers/gemini.rb +204 -0
  35. data/lib/little_ghost/providers/open_router/catalog_source.rb +42 -0
  36. data/lib/little_ghost/providers/open_router.rb +6 -2
  37. data/lib/little_ghost/providers/openai_compatible.rb +19 -23
  38. data/lib/little_ghost/providers/vertex_ai/credential_resolver.rb +90 -0
  39. data/lib/little_ghost/providers/vertex_ai.rb +38 -0
  40. data/lib/little_ghost/run.rb +7 -7
  41. data/lib/little_ghost/runtime.rb +10 -7
  42. data/lib/little_ghost/sandbox.rb +5 -5
  43. data/lib/little_ghost/session_store.rb +3 -3
  44. data/lib/little_ghost/structured_output.rb +2 -8
  45. data/lib/little_ghost/support/http_client.rb +186 -0
  46. data/lib/little_ghost/{providers → support}/sse_parser.rb +1 -1
  47. data/lib/little_ghost/tool.rb +5 -1
  48. data/lib/little_ghost/tool_registry.rb +2 -3
  49. data/lib/little_ghost/version.rb +1 -1
  50. data/lib/little_ghost/workflow.rb +1 -1
  51. data/lib/little_ghost.rb +21 -15
  52. metadata +28 -7
  53. data/docs/guides/Getting Started.md +0 -187
  54. data/lib/little_ghost/default_model_registry.rb +0 -71
  55. data/lib/little_ghost/model_registry.rb +0 -173
  56. data/lib/little_ghost/providers/http_transport.rb +0 -149
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module LittleGhost
7
+ # Immutable model identities, metadata, configuration readers, and catalogs.
8
+ module Models
9
+ # Resolves model facts from refreshed data and the snapshot packaged with
10
+ # LittleGhost. Refresh is always explicit.
11
+ class Catalog
12
+ ARRAY_ATTRIBUTES = %i[input_modalities output_modalities supported_parameters].freeze # :nodoc:
13
+
14
+ # Path to the offline model metadata snapshot packaged with the gem.
15
+ SNAPSHOT_PATH = File.expand_path("../data/model_catalog.json", __dir__)
16
+
17
+ # Builds a layered catalog from bundled and refreshed facts.
18
+ def initialize(sources: [], snapshot_path: SNAPSHOT_PATH, clock: -> { Time.now.utc })
19
+ @sources = Array(sources).freeze
20
+ @snapshot = load_snapshot(snapshot_path)
21
+ @refreshed = {}
22
+ @clock = clock
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # Returns immutable normalized details for +target+.
27
+ def details(target)
28
+ target = Target.parse(target)
29
+ records = [@snapshot[target.to_s], @mutex.synchronize { @refreshed[target.to_s] }].compact
30
+ merge_details(target, records)
31
+ end
32
+
33
+ # Refreshes one target or all models. Failed sources leave the last good
34
+ # catalog untouched and are returned to the caller for reporting.
35
+ def refresh!(target: nil)
36
+ requested = target && Target.parse(target)
37
+ records = {}
38
+ errors = []
39
+ @sources.each do |source|
40
+ values = source.refresh(target: requested)
41
+ normalized = normalize_records(
42
+ values,
43
+ source.name,
44
+ attribute_merge_strategies: source.attribute_merge_strategies
45
+ )
46
+ records = records.merge(normalized) { |_key, older, newer| merge_records(older, newer) }
47
+ rescue => error
48
+ errors << error
49
+ end
50
+ @mutex.synchronize { @refreshed = @refreshed.merge(records) { |_key, older, newer| merge_records(older, newer) } } unless records.empty?
51
+ {updated: records.keys.freeze, errors: errors.freeze}.freeze
52
+ end
53
+
54
+ private
55
+
56
+ def load_snapshot(path)
57
+ return {} unless File.file?(path)
58
+
59
+ normalize_records(JSON.parse(File.read(path)), "bundled")
60
+ rescue JSON::ParserError => error
61
+ raise ConfigurationError, "Bundled model catalog is invalid: #{error.message}"
62
+ end
63
+
64
+ def normalize_records(values, source, attribute_merge_strategies: {})
65
+ strategies = normalize_merge_strategies(attribute_merge_strategies)
66
+ values.to_h.each_with_object({}) do |(target, attributes), result|
67
+ data = attributes.to_h.transform_keys(&:to_sym)
68
+ provenance = data.delete(:provenance) || data.keys.to_h { |key| [key, source] }
69
+ provenance = provenance.to_h { |key, value| [key.to_sym, value] }
70
+ observed_at = data.delete(:observed_at)
71
+ validate_array_attributes!(data)
72
+ record_strategies = strategies.select { |attribute, _strategy| data.key?(attribute) }.freeze
73
+ validate_strategy_values!(data, record_strategies)
74
+ result[Target.parse(target).to_s] = {
75
+ attributes: data,
76
+ provenance:,
77
+ observed_at:,
78
+ attribute_merge_strategies: record_strategies
79
+ }
80
+ end
81
+ end
82
+
83
+ def merge_details(target, records)
84
+ merged = records.reduce({attributes: {}, provenance: {}, observed_at: nil}) { |left, right| merge_records(left, right) }
85
+ Details.new(
86
+ target:,
87
+ attributes: merged.fetch(:attributes),
88
+ provenance: merged.fetch(:provenance),
89
+ observed_at: merged[:observed_at]
90
+ )
91
+ end
92
+
93
+ def merge_records(left, right)
94
+ attributes = left.fetch(:attributes, {}).merge(right.fetch(:attributes, {}))
95
+ provenance = left.fetch(:provenance, {}).merge(right.fetch(:provenance, {}))
96
+ right_strategies = right.fetch(:attribute_merge_strategies, {})
97
+ apply_attribute_merge_strategies!(attributes, provenance, left, right, right_strategies)
98
+ strategies = left.fetch(:attribute_merge_strategies, {}).dup
99
+ right.fetch(:attributes, {}).each_key { |attribute| strategies.delete(attribute) }
100
+ strategies.merge!(right_strategies).freeze
101
+ {
102
+ attributes:,
103
+ provenance:,
104
+ observed_at: right[:observed_at] || left[:observed_at],
105
+ attribute_merge_strategies: strategies
106
+ }
107
+ end
108
+
109
+ def normalize_merge_strategies(strategies)
110
+ strategies.to_h.each_with_object({}) do |(attribute, strategy), normalized|
111
+ unless attribute.is_a?(String) || attribute.is_a?(Symbol)
112
+ raise ConfigurationError, "Catalog merge strategy attributes must be strings or symbols"
113
+ end
114
+ unless strategy == :union || strategy == "union"
115
+ raise ConfigurationError, "Unsupported catalog merge strategy: #{strategy}"
116
+ end
117
+
118
+ normalized[attribute.to_sym] = :union
119
+ end.freeze
120
+ end
121
+
122
+ def apply_attribute_merge_strategies!(attributes, provenance, left, right, strategies)
123
+ strategies.each do |attribute, strategy|
124
+ existing = left.fetch(:attributes, {})[attribute]
125
+ refreshed = right.fetch(:attributes, {})[attribute]
126
+ next unless existing && refreshed
127
+
128
+ attributes[attribute] = (existing + refreshed).uniq if strategy == :union
129
+ previous_source = left.fetch(:provenance, {})[attribute]
130
+ current_source = right.fetch(:provenance, {})[attribute]
131
+ sources = [previous_source, current_source].compact.uniq
132
+ provenance[attribute] = sources.join("+") unless sources.empty?
133
+ end
134
+ end
135
+
136
+ def validate_strategy_values!(attributes, strategies)
137
+ strategies.each_key do |attribute|
138
+ unless attributes.fetch(attribute).is_a?(Array)
139
+ raise ConfigurationError, "Catalog union attribute #{attribute} must be an array"
140
+ end
141
+ end
142
+ end
143
+
144
+ def validate_array_attributes!(attributes)
145
+ ARRAY_ATTRIBUTES.each do |attribute|
146
+ value = attributes[attribute]
147
+ next if value.nil? || value.is_a?(Array)
148
+
149
+ raise ConfigurationError, "Catalog attribute #{attribute} must be an array"
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LittleGhost
6
+ module Models
7
+ # Generates the deterministic provider-filtered catalog packaged in the gem.
8
+ module CatalogSnapshot # :nodoc:
9
+ NAMESPACES = {
10
+ "amazon-bedrock" => "bedrock", "anthropic" => "anthropic", "google" => "gemini",
11
+ "google-vertex" => "vertex_ai", "openai" => "openai", "openrouter" => "openrouter"
12
+ }.freeze
13
+
14
+ def self.generate(document)
15
+ result = {}
16
+ NAMESPACES.sort.each do |namespace, provider|
17
+ document.fetch(namespace).fetch("models").sort.each do |model_id, value|
18
+ attributes = normalize(value)
19
+ attributes["provenance"] = attributes.keys.reject { |key| key == "observed_at" }.to_h { |key| [key, "bundled:models.dev"] }
20
+ result["#{provider}:#{model_id}"] = attributes
21
+ end
22
+ end
23
+ JSON.pretty_generate(result) + "\n"
24
+ end
25
+
26
+ def self.normalize(value)
27
+ parameters = []
28
+ parameters << "tools" if value["tool_call"]
29
+ parameters << "structured_outputs" if value["structured_output"]
30
+ parameters << "temperature" if value["temperature"]
31
+ parameters << "reasoning" if value["reasoning"]
32
+ {
33
+ "context_window" => value.dig("limit", "context"),
34
+ "max_output_tokens" => value.dig("limit", "output"),
35
+ "input_modalities" => value.dig("modalities", "input"),
36
+ "output_modalities" => value.dig("modalities", "output"),
37
+ "supported_parameters" => parameters.empty? ? nil : parameters,
38
+ "pricing" => value["cost"],
39
+ "observed_at" => value["last_updated"] || value["release_date"]
40
+ }.compact
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "date"
5
+ require "pathname"
6
+ require "psych"
7
+
8
+ module LittleGhost
9
+ module Models
10
+ # Loads trusted provider connections or logical model profiles from YAML.
11
+ class Configuration
12
+ class << self
13
+ # Loads provider connections from +path+.
14
+ def providers(path)
15
+ document = read(path)
16
+ Providers::Configuration.new(mapping(document["providers"], path, "providers"))
17
+ end
18
+
19
+ # Loads model profiles and an optional default role from +path+.
20
+ def models(path)
21
+ document = read(path)
22
+ [mapping(document["models"], path, "models"), document["default_model"]&.to_s]
23
+ end
24
+
25
+ private
26
+
27
+ def read(path)
28
+ path = Pathname(path)
29
+ rendered = ERB.new(path.read, trim_mode: "-").result
30
+ Psych.safe_load(rendered, permitted_classes: [Date, Time], aliases: true) || {}
31
+ rescue Psych::Exception, NameError => error
32
+ raise ConfigurationError, "Invalid LittleGhost configuration in #{path}: #{error.message}"
33
+ end
34
+
35
+ def mapping(value, path, key)
36
+ raise ConfigurationError, "#{path}: #{key} must be a mapping" unless value.is_a?(Hash)
37
+
38
+ value.to_h { |name, child| [name.to_s, child.to_h] }.freeze
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module Models
5
+ # Immutable normalized facts about one physical model.
6
+ Details = Data.define(:target, :attributes, :provenance, :observed_at) do
7
+ def initialize(target:, attributes: {}, provenance: {}, observed_at: nil)
8
+ normalized = attributes.to_h.each_with_object({}) do |(key, value), result|
9
+ result[key.to_sym] = freeze_value(value)
10
+ end
11
+ sources = provenance.to_h.each_with_object({}) { |(key, value), result| result[key.to_sym] = value.to_s.freeze }
12
+ super(
13
+ target: Target.parse(target),
14
+ attributes: normalized.freeze,
15
+ provenance: sources.freeze,
16
+ observed_at:
17
+ )
18
+ end
19
+
20
+ def [](name) = attributes[name.to_sym]
21
+ def known? = !attributes.empty?
22
+ def pricing = self[:pricing] || {}.freeze
23
+ def input_modalities = self[:input_modalities]
24
+ def supported_parameters = self[:supported_parameters]
25
+ def context_window = self[:context_window] || self[:context_window_tokens]
26
+ def max_output_tokens = self[:max_output_tokens]
27
+
28
+ def to_h
29
+ attributes.merge(target: target.to_s, provenance:, observed_at:)
30
+ end
31
+
32
+ private
33
+
34
+ def freeze_value(value)
35
+ case value
36
+ when Hash
37
+ value.to_h { |key, child| [key.to_sym, freeze_value(child)] }.freeze
38
+ when Array
39
+ value.map { |child| freeze_value(child) }.freeze
40
+ when String
41
+ value.dup.freeze
42
+ else
43
+ value
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module Models
5
+ # Identifies one physical model through a named provider connection.
6
+ Target = Data.define(:provider, :model_id) do
7
+ def initialize(provider:, model_id:)
8
+ provider = provider.to_s.strip
9
+ model_id = model_id.to_s.strip
10
+ raise ConfigurationError, "Model target provider is required" if provider.empty?
11
+ raise ConfigurationError, "Model target model ID is required" if model_id.empty?
12
+
13
+ super(provider: provider.freeze, model_id: model_id.freeze)
14
+ end
15
+
16
+ # Parses +provider:model-id+, splitting only at the first colon.
17
+ def self.parse(value)
18
+ return value if value.is_a?(self)
19
+
20
+ provider, separator, model_id = value.to_s.partition(":")
21
+ raise ConfigurationError, "Model target must use provider:model-id" if separator.empty?
22
+
23
+ new(provider:, model_id:)
24
+ end
25
+
26
+ def to_s = "#{provider}:#{model_id}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Constructs built-in and application provider adapters from named provider
5
+ # connections. Register adapters during application configuration.
6
+ class ProviderRegistry
7
+ # :nodoc:
8
+ REQUEST_OPTIONS = %i[
9
+ app_name max_response_bytes max_retries max_retry_delay open_timeout read_timeout retries
10
+ ].freeze
11
+ # :nodoc:
12
+ BUILT_INS = {
13
+ "openai_compatible" => Providers::OpenAICompatible,
14
+ "openai" => Providers::OpenAI,
15
+ "openrouter" => Providers::OpenRouter,
16
+ "anthropic" => Providers::Anthropic,
17
+ "gemini" => Providers::Gemini,
18
+ "vertex_ai" => Providers::VertexAI,
19
+ "bedrock" => Providers::Bedrock
20
+ }.freeze
21
+
22
+ # Creates a registry with optional adapter factories keyed by adapter name.
23
+ # Application adapters supplement the built-in provider adapters.
24
+ def initialize(adapters: {})
25
+ @adapters = BUILT_INS.merge(adapters.to_h.transform_keys(&:to_s))
26
+ end
27
+
28
+ # Constructs the configured provider for +model+.
29
+ #
30
+ # Connection +configuration+ remains authoritative. The trusted per-profile
31
+ # +request+ mapping may adjust bounded request behavior, but cannot replace
32
+ # endpoints or credentials.
33
+ def build(adapter:, model:, configuration:, request: {}, **context)
34
+ factory = @adapters.fetch(adapter.to_s) do
35
+ raise AdapterLoadError, "Unknown provider adapter: #{adapter}"
36
+ end
37
+ options = symbolize(configuration).reject { |key, value| key == :adapter || value.nil? }
38
+ request_options = symbolize(request).reject { |_key, value| value.nil? }
39
+ unsupported = request_options.keys - REQUEST_OPTIONS
40
+ unless unsupported.empty?
41
+ raise ConfigurationError, "Unsupported request options for #{adapter}: #{unsupported.join(", ")}"
42
+ end
43
+ if request_options.key?(:retries)
44
+ request_options[:max_retries] ||= request_options[:retries]
45
+ request_options.delete(:retries)
46
+ end
47
+ if factory.is_a?(Class)
48
+ unless factory <= Providers::Base
49
+ raise AdapterLoadError, "Provider adapter #{adapter} must inherit LittleGhost::Providers::Base"
50
+ end
51
+
52
+ request_options.select! { |key| factory.request_options.include?(key) }
53
+ end
54
+ options.merge!(request_options)
55
+ provider = if factory.is_a?(Class)
56
+ factory.new(model:, **options)
57
+ else
58
+ factory.call(model:, configuration: options, **context)
59
+ end
60
+ unless provider.is_a?(Providers::Base)
61
+ raise AdapterLoadError, "Provider adapter #{adapter} must return a LittleGhost::Providers::Base"
62
+ end
63
+
64
+ provider
65
+ rescue ArgumentError => error
66
+ raise unless error.message.match?(/unknown keyword|missing keyword/)
67
+
68
+ raise ConfigurationError, "Invalid configuration for #{adapter}: #{error.message}"
69
+ end
70
+
71
+ private
72
+
73
+ def symbolize(value) = value.to_h { |key, child| [key.to_sym, child] }
74
+ end
75
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module Providers
5
+ class Anthropic < Base
6
+ # Enriches availability and limits from Anthropic's model list endpoint.
7
+ class CatalogSource < Models::Catalog::Source
8
+ URL = URI("https://api.anthropic.com/v1/models") # :nodoc:
9
+
10
+ # Creates a source for the named provider connection.
11
+ def initialize(provider:, credential_resolver:)
12
+ super(name: "anthropic")
13
+ @provider = provider
14
+ @credential_resolver = credential_resolver
15
+ end
16
+
17
+ def refresh(target: nil)
18
+ api_key = @credential_resolver.call.fetch("api_key")
19
+ values = JSON.parse(
20
+ Support::HTTPClient.new(open_timeout: 5, read_timeout: 30, max_response_bytes: 25 * 1024 * 1024)
21
+ .request(uri: URL, headers: {"x-api-key" => api_key, "anthropic-version" => "2023-06-01"})
22
+ ).fetch("data")
23
+ values.select! { |value| value["id"] == target.model_id } if target
24
+ values.to_h { |value| ["#{@provider}:#{value.fetch("id")}", {available: true, observed_at: value["created_at"]}.compact] }
25
+ rescue JSON::ParserError, KeyError => error
26
+ raise ProviderError, "Anthropic returned an invalid catalog: #{error.message}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require_relative "../support/http_client"
6
+ require_relative "../support/sse_parser"
7
+ require_relative "anthropic/catalog_source"
8
+
9
+ module LittleGhost
10
+ module Providers
11
+ # Zero-dependency Anthropic Messages API adapter.
12
+ class Anthropic < Base
13
+ # Request policy supported by the Anthropic HTTP client.
14
+ def self.request_options = %i[max_response_bytes open_timeout read_timeout].freeze
15
+
16
+ DEFAULT_BASE_URL = "https://api.anthropic.com/v1/" # :nodoc:
17
+
18
+ # Provider-owned model identifier.
19
+ attr_reader :model
20
+
21
+ # Creates an Anthropic Messages client for +model+.
22
+ def initialize(api_key:, model:, base_url: DEFAULT_BASE_URL, api_version: "2023-06-01",
23
+ open_timeout: 10, read_timeout: 120, max_response_bytes: Support::HTTPClient::DEFAULT_MAX_RESPONSE_BYTES,
24
+ transport: nil, **)
25
+ raise CredentialError, "Anthropic api_key is required" if api_key.to_s.empty?
26
+
27
+ @api_key = api_key
28
+ @api_version = api_version
29
+ @model = model
30
+ @transport = transport || Support::HTTPClient.new(base_url:, open_timeout:, read_timeout:, max_response_bytes:)
31
+ end
32
+
33
+ # Streams normalized events for +request+.
34
+ def stream(request)
35
+ return enum_for(__method__, request) unless block_given?
36
+
37
+ parser = Support::SSEParser.new
38
+ normalizer = Normalizer.new(model:)
39
+ @transport.stream(
40
+ path: "messages",
41
+ headers: {
42
+ "x-api-key" => @api_key,
43
+ "anthropic-version" => @api_version,
44
+ "content-type" => "application/json",
45
+ "accept" => "text/event-stream"
46
+ },
47
+ body: JSON.generate(request_body(request)),
48
+ cancellation_token: request.cancellation_token,
49
+ deadline: request.deadline
50
+ ) do |chunk|
51
+ parser.<<(chunk).each { |data| normalizer.consume(JSON.parse(data)).each { |event| yield event } }
52
+ end
53
+ parser.finish.each { |data| normalizer.consume(JSON.parse(data)).each { |event| yield event } }
54
+ normalizer.finish.each { |event| yield event }
55
+ rescue JSON::ParserError => error
56
+ raise ProtocolError, "Anthropic returned invalid JSON: #{error.message}"
57
+ end
58
+
59
+ # Reports tool and structured-output support from model metadata.
60
+ def capabilities(metadata: {})
61
+ parameters = metadata[:supported_parameters]
62
+ ModelCapabilities.new(
63
+ native_structured_output: parameters&.include?("structured_outputs"),
64
+ tools: true,
65
+ tool_choice: true,
66
+ supported_parameters: parameters
67
+ )
68
+ end
69
+
70
+ private
71
+
72
+ def request_body(request)
73
+ system, messages = request.messages.partition { |message| message.role == :system }
74
+ body = {
75
+ model:,
76
+ stream: true,
77
+ max_tokens: request.settings[:max_tokens] || 4096,
78
+ messages: messages.map { |message| anthropic_message(message) }
79
+ }
80
+ body[:system] = system.flat_map { |message| message.content.map { |block| anthropic_content(block) } } unless system.empty?
81
+ body[:tools] = request.tools.map { |tool| anthropic_tool(tool) } unless request.tools.empty?
82
+ body[:tool_choice] = (request.tool_choice == :required) ? {type: "any"} : {type: "tool", name: request.tool_choice[:name].to_s} if request.tool_choice
83
+ request.settings.each { |key, value| body[key] = value unless %i[reasoning_effort max_tokens].include?(key.to_sym) }
84
+ body
85
+ end
86
+
87
+ def anthropic_message(message)
88
+ {role: (message.role == :assistant) ? "assistant" : "user", content: message.content.map { |block| anthropic_content(block) }}
89
+ end
90
+
91
+ def anthropic_content(block)
92
+ case block
93
+ when Content::Text then {type: "text", text: block.text}
94
+ when Content::Image then {type: "image", source: {type: "base64", media_type: block.media_type, data: Base64.strict_encode64(block.data)}}
95
+ when Content::Document then {type: "document", source: {type: "base64", media_type: block.media_type, data: Base64.strict_encode64(block.data)}}
96
+ when Content::ToolUse then {type: "tool_use", id: block.id, name: block.name, input: block.input}
97
+ when Content::ToolResult
98
+ {type: "tool_result", tool_use_id: block.tool_use_id, content: Array(block.content).join("\n"), is_error: block.status == :error}
99
+ when Content::Reasoning then {type: "thinking", thinking: block.text, signature: block.signature}.compact
100
+ else raise ConfigurationError, "Unsupported Anthropic content block: #{block.class}"
101
+ end
102
+ end
103
+
104
+ def anthropic_tool(tool)
105
+ value = tool.is_a?(Hash) ? tool.transform_keys(&:to_sym) : {name: tool.name, description: tool.description, input_schema: tool.input_schema}
106
+ {name: value.fetch(:name), description: value[:description], input_schema: value[:input_schema] || {}}
107
+ end
108
+
109
+ class Normalizer # :nodoc:
110
+ def initialize(model:)
111
+ @model = model
112
+ @text = +""
113
+ @reasoning = +""
114
+ @tools = {}
115
+ @usage = Usage.new
116
+ @terminal = false
117
+ end
118
+
119
+ def consume(event)
120
+ case event["type"]
121
+ when "message_start"
122
+ message = event.fetch("message")
123
+ @id = message["id"]
124
+ @usage = usage(message["usage"])
125
+ [StreamEvent.build(:message_start, id: @id, model: message["model"] || @model)]
126
+ when "content_block_start"
127
+ start_block(event)
128
+ when "content_block_delta"
129
+ delta_block(event)
130
+ when "content_block_stop"
131
+ stop_block(event)
132
+ when "message_delta"
133
+ @stop_reason = stop_reason(event.dig("delta", "stop_reason"))
134
+ @usage = usage(event["usage"], previous: @usage)
135
+ [StreamEvent.build(:usage, usage: @usage)]
136
+ when "message_stop"
137
+ @terminal = true
138
+ []
139
+ when "error"
140
+ raise ProviderError, "Anthropic request failed: #{event.dig("error", "message") || "unknown error"}"
141
+ else
142
+ []
143
+ end
144
+ end
145
+
146
+ def finish
147
+ raise ProtocolError, "Anthropic stream ended before message_stop" unless @terminal
148
+
149
+ blocks = []
150
+ blocks << Content::Reasoning.new(text: @reasoning) unless @reasoning.empty?
151
+ blocks << Content::Text.new(text: @text) unless @text.empty?
152
+ @tools.sort.each { |_index, tool| blocks << Content::ToolUse.new(id: tool[:id], name: tool[:name], input: parse_input(tool[:input])) }
153
+ response = ModelResponse.new(
154
+ message: Message.new(role: :assistant, content: blocks),
155
+ stop_reason: @stop_reason || (@tools.empty? ? :end_turn : :tool_use),
156
+ usage: @usage,
157
+ metadata: {id: @id, model: @model}
158
+ )
159
+ [StreamEvent.build(:message_stop, response:)]
160
+ end
161
+
162
+ private
163
+
164
+ def start_block(event)
165
+ block = event.fetch("content_block")
166
+ return [] unless block["type"] == "tool_use"
167
+
168
+ index = event.fetch("index")
169
+ @tools[index] = {id: block["id"], name: block["name"], input: +""}
170
+ [StreamEvent.build(:tool_call_start, index:, id: block["id"], name: block["name"])]
171
+ end
172
+
173
+ def delta_block(event)
174
+ delta = event.fetch("delta")
175
+ case delta["type"]
176
+ when "text_delta"
177
+ @text << delta.fetch("text")
178
+ [StreamEvent.build(:text_delta, text: delta.fetch("text"))]
179
+ when "thinking_delta"
180
+ @reasoning << delta.fetch("thinking")
181
+ [StreamEvent.build(:reasoning_delta, text: delta.fetch("thinking"))]
182
+ when "input_json_delta"
183
+ index = event.fetch("index")
184
+ arguments = delta.fetch("partial_json")
185
+ @tools.fetch(index)[:input] << arguments
186
+ [StreamEvent.build(:tool_call_delta, index:, arguments:)]
187
+ else []
188
+ end
189
+ end
190
+
191
+ def stop_block(event)
192
+ index = event.fetch("index")
193
+ tool = @tools[index]
194
+ return [] unless tool
195
+
196
+ use = Content::ToolUse.new(id: tool[:id], name: tool[:name], input: parse_input(tool[:input]))
197
+ [StreamEvent.build(:tool_call_stop, index:, tool_use: use)]
198
+ end
199
+
200
+ def parse_input(value) = value.empty? ? {} : JSON.parse(value)
201
+
202
+ def usage(value, previous: Usage.new)
203
+ value ||= {}
204
+ cache_read = value["cache_read_input_tokens"] || previous.cache_read_tokens
205
+ cache_write = value["cache_creation_input_tokens"] || previous.cache_write_tokens
206
+ input = value["input_tokens"] || previous.input_tokens
207
+ Usage.new(input_tokens: input, output_tokens: value["output_tokens"] || previous.output_tokens,
208
+ cache_read_tokens: cache_read, cache_write_tokens: cache_write)
209
+ end
210
+
211
+ def stop_reason(value)
212
+ case value
213
+ when "tool_use" then :tool_use
214
+ when "max_tokens" then :max_tokens
215
+ when "refusal" then :content_filter
216
+ else :end_turn
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end