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.
- checksums.yaml +4 -4
- data/README.md +50 -47
- data/docs/guides/{Core Concepts.md → core_concepts.md} +40 -20
- data/docs/guides/getting_started.md +164 -0
- data/lib/little_ghost/ag_ui/adapter.rb +1 -1
- data/lib/little_ghost/agent/context_management.rb +1 -4
- data/lib/little_ghost/agent.rb +94 -26
- data/lib/little_ghost/agent_builder.rb +1 -1
- data/lib/little_ghost/configuration.rb +210 -29
- data/lib/little_ghost/data/model_catalog.json +19178 -0
- data/lib/little_ghost/errors.rb +6 -0
- data/lib/little_ghost/invocation.rb +2 -17
- data/lib/little_ghost/model.rb +53 -33
- data/lib/little_ghost/model_capabilities.rb +6 -5
- data/lib/little_ghost/model_resolver.rb +316 -0
- data/lib/little_ghost/models/catalog/models_dev_source.rb +62 -0
- data/lib/little_ghost/models/catalog/source.rb +30 -0
- data/lib/little_ghost/models/catalog.rb +154 -0
- data/lib/little_ghost/models/catalog_snapshot.rb +44 -0
- data/lib/little_ghost/models/configuration.rb +43 -0
- data/lib/little_ghost/models/details.rb +48 -0
- data/lib/little_ghost/models/target.rb +29 -0
- data/lib/little_ghost/provider_registry.rb +75 -0
- data/lib/little_ghost/providers/anthropic/catalog_source.rb +31 -0
- data/lib/little_ghost/providers/anthropic.rb +222 -0
- data/lib/little_ghost/providers/base.rb +46 -0
- data/lib/little_ghost/providers/bedrock/aws_protocol.rb +132 -0
- data/lib/little_ghost/providers/bedrock/catalog_source.rb +200 -0
- data/lib/little_ghost/providers/bedrock/credential_resolver.rb +123 -0
- data/lib/little_ghost/providers/bedrock/http_client.rb +78 -0
- data/lib/little_ghost/providers/bedrock.rb +23 -15
- data/lib/little_ghost/providers/configuration.rb +79 -0
- data/lib/little_ghost/providers/gemini/catalog_source.rb +35 -0
- data/lib/little_ghost/providers/gemini.rb +204 -0
- data/lib/little_ghost/providers/open_router/catalog_source.rb +42 -0
- data/lib/little_ghost/providers/open_router.rb +6 -2
- data/lib/little_ghost/providers/openai_compatible.rb +19 -23
- data/lib/little_ghost/providers/vertex_ai/credential_resolver.rb +90 -0
- data/lib/little_ghost/providers/vertex_ai.rb +38 -0
- data/lib/little_ghost/run.rb +7 -7
- data/lib/little_ghost/runtime.rb +10 -7
- data/lib/little_ghost/sandbox.rb +5 -5
- data/lib/little_ghost/session_store.rb +3 -3
- data/lib/little_ghost/structured_output.rb +2 -8
- data/lib/little_ghost/support/http_client.rb +186 -0
- data/lib/little_ghost/{providers → support}/sse_parser.rb +1 -1
- data/lib/little_ghost/tool.rb +5 -1
- data/lib/little_ghost/tool_registry.rb +2 -3
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +1 -1
- data/lib/little_ghost.rb +21 -15
- metadata +28 -7
- data/docs/guides/Getting Started.md +0 -187
- data/lib/little_ghost/default_model_registry.rb +0 -71
- data/lib/little_ghost/model_registry.rb +0 -173
- 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
|