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
data/lib/little_ghost/errors.rb
CHANGED
|
@@ -5,6 +5,12 @@ module LittleGhost
|
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
# Raised for invalid framework or application configuration.
|
|
7
7
|
class ConfigurationError < Error; end
|
|
8
|
+
# Raised when an abstract framework method has no concrete implementation.
|
|
9
|
+
class AbstractMethodError < Error; end
|
|
10
|
+
# Raised when a configured provider adapter cannot be constructed.
|
|
11
|
+
class AdapterLoadError < ConfigurationError; end
|
|
12
|
+
# Raised when no usable credentials can be resolved for a provider.
|
|
13
|
+
class CredentialError < ConfigurationError; end
|
|
8
14
|
# Raised when an invocation payload or operation is invalid.
|
|
9
15
|
class InvocationError < Error; end
|
|
10
16
|
# Raised when an invocation contains an unsupported input form.
|
|
@@ -33,12 +33,11 @@ module LittleGhost
|
|
|
33
33
|
"history" => -> { [] },
|
|
34
34
|
"settings" => -> { {} },
|
|
35
35
|
"context" => -> { {} },
|
|
36
|
-
"metadata" => -> { {} }
|
|
37
|
-
"model_profiles" => -> { {} }
|
|
36
|
+
"metadata" => -> { {} }
|
|
38
37
|
}.freeze # :nodoc:
|
|
39
38
|
|
|
40
39
|
ACCESSORS = %i[
|
|
41
|
-
message history settings context metadata
|
|
40
|
+
message history settings context metadata
|
|
42
41
|
run_id invocation_id session_id actor_id
|
|
43
42
|
].freeze # :nodoc:
|
|
44
43
|
|
|
@@ -88,14 +87,6 @@ module LittleGhost
|
|
|
88
87
|
# :call-seq:
|
|
89
88
|
# metadata() -> Hash
|
|
90
89
|
|
|
91
|
-
##
|
|
92
|
-
# Per-request overrides for logical model profiles. Overrides can select a
|
|
93
|
-
# provider and model, so applications must construct or allowlist them at a
|
|
94
|
-
# trusted control-plane boundary.
|
|
95
|
-
# :method: model_profiles
|
|
96
|
-
# :call-seq:
|
|
97
|
-
# model_profiles() -> Hash
|
|
98
|
-
|
|
99
90
|
##
|
|
100
91
|
# The caller-supplied or generated top-level run identifier.
|
|
101
92
|
# :method: run_id
|
|
@@ -138,12 +129,6 @@ module LittleGhost
|
|
|
138
129
|
# :call-seq:
|
|
139
130
|
# metadata=(value) -> value
|
|
140
131
|
|
|
141
|
-
##
|
|
142
|
-
# Replaces the trusted per-request model profile overrides.
|
|
143
|
-
# :method: model_profiles=
|
|
144
|
-
# :call-seq:
|
|
145
|
-
# model_profiles=(value) -> value
|
|
146
|
-
|
|
147
132
|
##
|
|
148
133
|
# Replaces the top-level run identifier.
|
|
149
134
|
# :method: run_id=
|
data/lib/little_ghost/model.rb
CHANGED
|
@@ -1,84 +1,104 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module LittleGhost
|
|
4
|
-
#
|
|
5
|
-
|
|
4
|
+
# Interface for executable model implementations accepted by agents.
|
|
5
|
+
module ModelInterface
|
|
6
|
+
# Canonical physical provider and model identifier.
|
|
7
|
+
def target = Models::Target.parse("custom:#{self.class.name || "anonymous"}")
|
|
8
|
+
# Provider-owned model identifier without the connection name.
|
|
9
|
+
def model_id = target.model_id
|
|
10
|
+
# Logical application role that selected this model, when available.
|
|
11
|
+
def role = nil
|
|
12
|
+
# Immutable capabilities, limits, modalities, and pricing facts.
|
|
13
|
+
def details = Models::Details.new(target:)
|
|
14
|
+
# Normalized feature support used for request strategy selection.
|
|
15
|
+
def capabilities = ModelCapabilities.permissive
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Model is the resolved connection between an agent selection and a provider.
|
|
19
|
+
# It keeps provider behavior behind one executable interface whether an agent
|
|
20
|
+
# selected a role, canonical target, or inline configuration.
|
|
6
21
|
#
|
|
7
22
|
# It merges profile settings into every ModelRequest, validates attachment
|
|
8
23
|
# modalities declared in metadata, lets providers prepare capability-sensitive
|
|
9
24
|
# requests, and delegates the normalized stream to the provider.
|
|
10
25
|
class Model
|
|
11
|
-
|
|
26
|
+
include ModelInterface
|
|
12
27
|
|
|
13
|
-
# Provider object
|
|
14
|
-
#
|
|
15
|
-
attr_reader :provider, :
|
|
28
|
+
# Provider object, canonical target, default settings, normalized model
|
|
29
|
+
# details, and logical application role.
|
|
30
|
+
attr_reader :provider, :target, :settings, :details, :role
|
|
16
31
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
raise ArgumentError, "
|
|
21
|
-
raise ArgumentError, "model is required" if (id || model).nil? || (id || model).to_s.empty?
|
|
32
|
+
# Connects a provider adapter to its canonical +target+, profile +settings+,
|
|
33
|
+
# optional model +details+, and logical +role+.
|
|
34
|
+
def initialize(provider:, target:, settings: {}, details: nil, role: nil)
|
|
35
|
+
raise ArgumentError, "provider must be a LittleGhost::Providers::Base" unless provider.is_a?(Providers::Base)
|
|
22
36
|
|
|
23
37
|
@provider = provider
|
|
24
|
-
@
|
|
25
|
-
@id = (id || model)&.to_s
|
|
38
|
+
@target = Models::Target.parse(target)
|
|
26
39
|
@settings = settings.to_h.transform_keys(&:to_sym).freeze
|
|
27
40
|
@role = role&.to_s
|
|
28
|
-
|
|
29
|
-
@metadata = profile_metadata.merge(
|
|
30
|
-
provider: @provider_name,
|
|
31
|
-
model_id: @id,
|
|
32
|
-
model_role: @role
|
|
33
|
-
).freeze
|
|
41
|
+
@details = details || Models::Details.new(target: @target)
|
|
34
42
|
end
|
|
35
43
|
|
|
44
|
+
# Provider-owned identifier from the canonical target.
|
|
45
|
+
def model_id = target.model_id
|
|
46
|
+
|
|
36
47
|
# Streams +request+ through the configured provider.
|
|
37
48
|
#
|
|
38
49
|
# Profile settings are defaults; settings on +request+ take precedence.
|
|
39
50
|
def stream(request, &block)
|
|
40
51
|
validate_input_modalities!(request)
|
|
52
|
+
configured_settings = settings.merge(request.settings)
|
|
53
|
+
configured_max_tokens = if request.settings.key?(:max_tokens)
|
|
54
|
+
request.settings[:max_tokens]
|
|
55
|
+
elsif request.settings.key?("max_tokens")
|
|
56
|
+
request.settings["max_tokens"]
|
|
57
|
+
else
|
|
58
|
+
settings[:max_tokens] || settings["max_tokens"]
|
|
59
|
+
end
|
|
60
|
+
if configured_max_tokens && details.max_output_tokens
|
|
61
|
+
configured_settings.delete(:max_tokens)
|
|
62
|
+
configured_settings.delete("max_tokens")
|
|
63
|
+
configured_settings[:max_tokens] = [configured_max_tokens, details.max_output_tokens].min
|
|
64
|
+
end
|
|
41
65
|
configured_request = ModelRequest.new(
|
|
42
66
|
messages: request.messages,
|
|
43
67
|
tools: request.tools,
|
|
44
|
-
settings:
|
|
68
|
+
settings: configured_settings,
|
|
45
69
|
output_schema: request.output_schema,
|
|
46
70
|
tool_choice: request.tool_choice,
|
|
47
71
|
required_capabilities: request.required_capabilities,
|
|
48
72
|
cancellation_token: request.cancellation_token,
|
|
49
73
|
deadline: request.deadline
|
|
50
74
|
)
|
|
51
|
-
|
|
52
|
-
configured_request = provider.prepare_request(configured_request, capabilities:)
|
|
53
|
-
end
|
|
75
|
+
configured_request = provider.prepare_request(configured_request, capabilities:)
|
|
54
76
|
provider.stream(configured_request, &block)
|
|
55
77
|
end
|
|
56
78
|
|
|
57
|
-
# Uses advertised provider capabilities
|
|
58
|
-
# contract for providers that do not advertise them.
|
|
79
|
+
# Uses advertised provider capabilities.
|
|
59
80
|
def capabilities
|
|
60
|
-
@capabilities ||=
|
|
61
|
-
provider.capabilities(metadata:)
|
|
62
|
-
else
|
|
63
|
-
ModelCapabilities.legacy
|
|
64
|
-
end
|
|
81
|
+
@capabilities ||= provider.capabilities(metadata: details.attributes)
|
|
65
82
|
end
|
|
66
83
|
|
|
67
84
|
private
|
|
68
85
|
|
|
69
86
|
def validate_input_modalities!(request)
|
|
70
|
-
supported =
|
|
87
|
+
supported = details.input_modalities
|
|
71
88
|
return unless supported
|
|
72
89
|
|
|
73
90
|
required = request.messages.flat_map do |message|
|
|
74
91
|
message.content.filter_map do |block|
|
|
75
92
|
case block
|
|
76
93
|
when Content::Image then "image"
|
|
77
|
-
when Content::Document then "file"
|
|
94
|
+
when Content::Document then (block.media_type == "application/pdf") ? "pdf" : "file"
|
|
78
95
|
end
|
|
79
96
|
end
|
|
80
97
|
end.uniq
|
|
81
|
-
|
|
98
|
+
available = Array(supported).map { |value| value.to_s.downcase }
|
|
99
|
+
missing = required.reject do |modality|
|
|
100
|
+
available.include?(modality) || (modality == "pdf" && available.include?("file"))
|
|
101
|
+
end
|
|
82
102
|
return if missing.empty?
|
|
83
103
|
|
|
84
104
|
raise UnsupportedInputError,
|
|
@@ -45,8 +45,9 @@ module LittleGhost
|
|
|
45
45
|
names.flatten.any? { |name| supported_parameters.include?(name.to_s) }
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Supplies
|
|
49
|
-
|
|
48
|
+
# Supplies a permissive capability set when a provider accepts every
|
|
49
|
+
# optional model feature.
|
|
50
|
+
def self.permissive
|
|
50
51
|
new(native_structured_output: true, tools: true, tool_choice: true)
|
|
51
52
|
end
|
|
52
53
|
|
|
@@ -115,9 +116,9 @@ module LittleGhost
|
|
|
115
116
|
# capability set supports every name.
|
|
116
117
|
|
|
117
118
|
##
|
|
118
|
-
# :singleton-method:
|
|
119
|
-
# Supplies
|
|
120
|
-
#
|
|
119
|
+
# :singleton-method: permissive
|
|
120
|
+
# Supplies a permissive capability set for providers that accept every
|
|
121
|
+
# optional model feature.
|
|
121
122
|
|
|
122
123
|
##
|
|
123
124
|
# :singleton-method: unknown
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Resolves logical profiles, canonical +provider:model-id+ targets, and inline
|
|
5
|
+
# model mappings into executable Model objects. Subclasses may override
|
|
6
|
+
# #resolve, #details, or #refresh! while preserving those public signatures.
|
|
7
|
+
class ModelResolver
|
|
8
|
+
DEFAULT_CREDENTIALS = [ # :nodoc:
|
|
9
|
+
["LITTLEGHOST_OPENROUTER_API_KEY", "openrouter"],
|
|
10
|
+
["LITTLEGHOST_OPENAI_API_KEY", "openai"],
|
|
11
|
+
["OPENROUTER_API_KEY", "openrouter"],
|
|
12
|
+
["OPENAI_API_KEY", "openai"]
|
|
13
|
+
].freeze
|
|
14
|
+
DEFAULT_MODELS = { # :nodoc:
|
|
15
|
+
"openrouter" => "openai/gpt-5.6-luna",
|
|
16
|
+
"openai" => "gpt-5.6-luna"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Provider configuration and model metadata catalog.
|
|
20
|
+
attr_reader :providers, :catalog
|
|
21
|
+
|
|
22
|
+
# Builds a resolver from explicit provider connections and profiles. With
|
|
23
|
+
# neither, conventional credentials select GPT-5.6 Luna.
|
|
24
|
+
def initialize(providers: nil, profiles: nil, default_model: nil, provider_registry: ProviderRegistry.new,
|
|
25
|
+
catalog: nil, catalog_sources: [], provider_adapters: {}, credential_resolver: nil)
|
|
26
|
+
unless providers.nil? || providers.is_a?(Providers::Configuration)
|
|
27
|
+
raise ArgumentError, "providers must be a LittleGhost::Providers::Configuration"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@providers = providers
|
|
31
|
+
@connections = providers&.connections || {}
|
|
32
|
+
@profiles = normalize_profiles(profiles || {})
|
|
33
|
+
@default_model = default_model&.to_s || "default"
|
|
34
|
+
@provider_registry = provider_adapters.empty? ? provider_registry : ProviderRegistry.new(adapters: provider_adapters)
|
|
35
|
+
@credential_resolver = credential_resolver
|
|
36
|
+
configure_default_providers unless providers
|
|
37
|
+
configure_default_profiles unless profiles
|
|
38
|
+
sources = catalog_sources.empty? ? built_in_catalog_sources : catalog_sources
|
|
39
|
+
@catalog = catalog || Models::Catalog.new(sources:)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Logical role used when an agent does not declare one.
|
|
43
|
+
attr_reader :default_model
|
|
44
|
+
|
|
45
|
+
# Resolves a logical role, canonical target, or inline model mapping into an
|
|
46
|
+
# executable Model. Inline mappings require +provider+ and +model+; remaining
|
|
47
|
+
# entries are trusted model settings. The optional +profiles+ mapping applies
|
|
48
|
+
# only to role selections and is never read from +invocation+.
|
|
49
|
+
def resolve(selection, invocation: nil, context: nil, profiles: nil, **options)
|
|
50
|
+
base, role = normalize_selection(selection)
|
|
51
|
+
if role
|
|
52
|
+
base = resolved_role(role)
|
|
53
|
+
profile_overlays = profiles || {}
|
|
54
|
+
override_names(role).each do |profile|
|
|
55
|
+
base = merge(base, profile_overlays[profile] || profile_overlays[profile.to_sym])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
target = Models::Target.parse(base.fetch(:target))
|
|
59
|
+
provider_config = @connections.fetch(target.provider) do
|
|
60
|
+
raise ConfigurationError, "Model target references unknown provider #{target.provider}"
|
|
61
|
+
end
|
|
62
|
+
provider_config = provider_config.merge(resolved_credentials(target.provider, provider_config)) if @credential_resolver
|
|
63
|
+
details = details_for(target, base[:details])
|
|
64
|
+
settings = clamp_output_tokens(base.fetch(:settings), details)
|
|
65
|
+
adapter = provider_config.fetch("adapter")
|
|
66
|
+
provider = @provider_registry.build(
|
|
67
|
+
adapter:,
|
|
68
|
+
model: target.model_id,
|
|
69
|
+
configuration: provider_config,
|
|
70
|
+
request: base.fetch(:request),
|
|
71
|
+
role:,
|
|
72
|
+
settings:,
|
|
73
|
+
details:,
|
|
74
|
+
invocation:,
|
|
75
|
+
context:,
|
|
76
|
+
**options
|
|
77
|
+
)
|
|
78
|
+
Model.new(provider:, target:, settings:, details:, role:)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns normalized metadata for +target+ without constructing a provider.
|
|
82
|
+
def details(target) = catalog.details(target)
|
|
83
|
+
# Refreshes configured metadata sources, retaining stale data on failure.
|
|
84
|
+
def refresh!(target: nil) = catalog.refresh!(target:)
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def resolved_credentials(provider, configuration)
|
|
89
|
+
values = @credential_resolver.call(
|
|
90
|
+
provider:,
|
|
91
|
+
adapter: configuration.fetch("adapter"),
|
|
92
|
+
configuration: configuration.dup.freeze
|
|
93
|
+
)
|
|
94
|
+
raise CredentialError, "Credential resolver for #{provider} must return a mapping" unless values.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
values.to_h { |key, value| [key.to_s, value] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def details_for(target, snapshot)
|
|
100
|
+
return details(target) unless snapshot
|
|
101
|
+
|
|
102
|
+
values = snapshot.to_h.transform_keys(&:to_sym)
|
|
103
|
+
snapshot_target = Models::Target.parse(values.delete(:target) || target)
|
|
104
|
+
raise ConfigurationError, "Model details target must match #{target}" unless snapshot_target == target
|
|
105
|
+
|
|
106
|
+
Models::Details.new(
|
|
107
|
+
target:,
|
|
108
|
+
attributes: values.except(:provenance, :observed_at),
|
|
109
|
+
provenance: values[:provenance] || {},
|
|
110
|
+
observed_at: values[:observed_at]
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def clamp_output_tokens(settings, details)
|
|
115
|
+
configured = settings[:max_tokens]
|
|
116
|
+
advertised = details.max_output_tokens
|
|
117
|
+
return settings unless configured && advertised
|
|
118
|
+
|
|
119
|
+
settings.merge(max_tokens: [configured, advertised].min)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def built_in_catalog_sources
|
|
123
|
+
adapters = @connections.to_h { |name, values| [name, values["adapter"].to_s] }
|
|
124
|
+
sources = [Models::Catalog::ModelsDevSource.new(provider_adapters: adapters)]
|
|
125
|
+
@connections.each do |name, values|
|
|
126
|
+
credentials = -> { catalog_credentials(name, values) }
|
|
127
|
+
case values["adapter"].to_s
|
|
128
|
+
when "openrouter"
|
|
129
|
+
sources << Providers::OpenRouter::CatalogSource.new(provider: name, credential_resolver: credentials)
|
|
130
|
+
when "anthropic"
|
|
131
|
+
sources << Providers::Anthropic::CatalogSource.new(provider: name, credential_resolver: credentials)
|
|
132
|
+
when "gemini"
|
|
133
|
+
sources << Providers::Gemini::CatalogSource.new(provider: name, credential_resolver: credentials)
|
|
134
|
+
when "bedrock"
|
|
135
|
+
resolver = values["credential_resolver"] || bedrock_credential_resolver(name, values)
|
|
136
|
+
region = values["region"] || (resolver.region if resolver.is_a?(Providers::Bedrock::CredentialResolver))
|
|
137
|
+
sources << Providers::Bedrock::CatalogSource.new(provider: name, region:, credential_resolver: resolver) if region
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
sources
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def catalog_credentials(provider, configuration)
|
|
144
|
+
return configuration unless @credential_resolver
|
|
145
|
+
|
|
146
|
+
configuration.merge(resolved_credentials(provider, configuration))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def bedrock_credential_resolver(provider, configuration)
|
|
150
|
+
return Providers::Bedrock::CredentialResolver.new unless @credential_resolver
|
|
151
|
+
|
|
152
|
+
fallback = Providers::Bedrock::CredentialResolver.new
|
|
153
|
+
lambda do
|
|
154
|
+
values = catalog_credentials(provider, configuration)
|
|
155
|
+
next values["credentials"] if values["credentials"]
|
|
156
|
+
|
|
157
|
+
access_key_id = values["access_key_id"] || values["aws_access_key_id"]
|
|
158
|
+
secret_access_key = values["secret_access_key"] || values["aws_secret_access_key"]
|
|
159
|
+
if access_key_id || secret_access_key
|
|
160
|
+
Providers::Bedrock::Credentials.new(
|
|
161
|
+
access_key_id:,
|
|
162
|
+
secret_access_key:,
|
|
163
|
+
session_token: values["session_token"] || values["aws_session_token"]
|
|
164
|
+
)
|
|
165
|
+
else
|
|
166
|
+
fallback.call
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def configure_default_providers
|
|
172
|
+
connections = DEFAULT_CREDENTIALS.each_with_object({}) do |(variable, provider), configured|
|
|
173
|
+
next if configured.key?(provider) || ENV[variable].to_s.strip.empty?
|
|
174
|
+
|
|
175
|
+
configured[provider] = {"adapter" => provider, "api_key" => ENV.fetch(variable)}
|
|
176
|
+
end
|
|
177
|
+
if connections.any?
|
|
178
|
+
@connections = connections
|
|
179
|
+
else
|
|
180
|
+
@connections = {"unconfigured" => {"adapter" => "unconfigured"}}
|
|
181
|
+
missing = "No API key is configured for the default model. Set #{DEFAULT_CREDENTIALS.map(&:first).join(", ")}, configure providers inline, or add config/little_ghost/providers.yml."
|
|
182
|
+
@provider_registry = ProviderRegistry.new(adapters: {
|
|
183
|
+
"unconfigured" => ->(**) { raise CredentialError, missing }
|
|
184
|
+
})
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def configure_default_profiles
|
|
189
|
+
connection, values = @connections.find { |_name, options| DEFAULT_MODELS.key?(options["adapter"].to_s) }
|
|
190
|
+
@profiles = if connection
|
|
191
|
+
{"default" => {"target" => "#{connection}:#{DEFAULT_MODELS.fetch(values["adapter"].to_s)}"}}
|
|
192
|
+
else
|
|
193
|
+
{"default" => {"target" => "unconfigured:#{DEFAULT_MODELS.fetch("openai")}"}}
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def normalize_selection(selection)
|
|
198
|
+
case selection
|
|
199
|
+
when Hash
|
|
200
|
+
[inline_selection(selection), nil]
|
|
201
|
+
when String, Symbol
|
|
202
|
+
value = selection.to_s
|
|
203
|
+
value.include?(":") ? [{target: value, settings: {}, request: {}}, nil] : [nil, value]
|
|
204
|
+
else
|
|
205
|
+
raise ConfigurationError, "Model selection must be a role, provider:model target, or configuration mapping"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def inline_selection(selection)
|
|
210
|
+
values = selection.to_h do |key, value|
|
|
211
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
212
|
+
raise ConfigurationError, "Inline model configuration keys must be strings or symbols"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
[key.to_sym, immutable_selection_value(value)]
|
|
216
|
+
end
|
|
217
|
+
provider = values.delete(:provider)
|
|
218
|
+
model_id = values.delete(:model)
|
|
219
|
+
raise ConfigurationError, "Inline model configuration requires provider" if provider.to_s.strip.empty?
|
|
220
|
+
raise ConfigurationError, "Inline model configuration requires model" if model_id.to_s.strip.empty?
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
target: Models::Target.new(provider:, model_id:),
|
|
224
|
+
settings: values.freeze,
|
|
225
|
+
request: {}
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def immutable_selection_value(value)
|
|
230
|
+
case value
|
|
231
|
+
when Hash
|
|
232
|
+
value.to_h { |key, child| [key, immutable_selection_value(child)] }.freeze
|
|
233
|
+
when Array
|
|
234
|
+
value.map { |child| immutable_selection_value(child) }.freeze
|
|
235
|
+
when String
|
|
236
|
+
value.dup.freeze
|
|
237
|
+
else
|
|
238
|
+
value
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def resolved_role(role)
|
|
243
|
+
profile_name = profile_for(role)
|
|
244
|
+
resolved_profile(profile_name)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def normalize_profiles(profiles)
|
|
248
|
+
raise ConfigurationError, "models must be a mapping" unless profiles.is_a?(Hash)
|
|
249
|
+
|
|
250
|
+
profiles.to_h do |name, profile|
|
|
251
|
+
role = name.to_s
|
|
252
|
+
validate_role_name!(role)
|
|
253
|
+
raise ConfigurationError, "models.#{role} must be a mapping" unless profile.is_a?(Hash)
|
|
254
|
+
|
|
255
|
+
parent = profile["inherits"] || profile[:inherits]
|
|
256
|
+
validate_role_name!(parent.to_s) if parent
|
|
257
|
+
[role, profile.to_h]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def validate_role_name!(role)
|
|
262
|
+
return unless role.include?(":")
|
|
263
|
+
|
|
264
|
+
raise ConfigurationError, "Model role names cannot contain ':' (#{role})"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def profile_for(role)
|
|
268
|
+
parts = role.split(".")
|
|
269
|
+
until parts.empty?
|
|
270
|
+
name = parts.join(".")
|
|
271
|
+
return name if @profiles.key?(name)
|
|
272
|
+
parts.pop
|
|
273
|
+
end
|
|
274
|
+
raise ConfigurationError, "Unknown model profile: #{role}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def resolved_profile(name, seen = [])
|
|
278
|
+
raise ConfigurationError, "Circular model profile inheritance: #{[*seen, name].join(" -> ")}" if seen.include?(name)
|
|
279
|
+
|
|
280
|
+
raw = @profiles.fetch(name)
|
|
281
|
+
profile = normalize(raw)
|
|
282
|
+
parent_name = raw["inherits"] || raw[:inherits]
|
|
283
|
+
return profile unless parent_name
|
|
284
|
+
|
|
285
|
+
merge(resolved_profile(parent_name.to_s, [*seen, name]), profile)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def normalize(value)
|
|
289
|
+
values = value.to_h.transform_keys(&:to_sym)
|
|
290
|
+
{
|
|
291
|
+
target: values[:target],
|
|
292
|
+
settings: values.fetch(:settings, {}).to_h.transform_keys(&:to_sym),
|
|
293
|
+
request: values.fetch(:request, {}).to_h.transform_keys(&:to_sym),
|
|
294
|
+
details: values[:details]
|
|
295
|
+
}.compact
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def merge(base, override)
|
|
299
|
+
return base unless override
|
|
300
|
+
|
|
301
|
+
values = normalize(override)
|
|
302
|
+
base.merge(values).merge(
|
|
303
|
+
target: values[:target] || base[:target],
|
|
304
|
+
settings: base.fetch(:settings, {}).merge(values.fetch(:settings, {})),
|
|
305
|
+
request: base.fetch(:request, {}).merge(values.fetch(:request, {}))
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def override_names(role)
|
|
310
|
+
names = []
|
|
311
|
+
parts = role.split(".")
|
|
312
|
+
1.upto(parts.length) { |length| names << parts.first(length).join(".") }
|
|
313
|
+
names
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module LittleGhost
|
|
7
|
+
module Models
|
|
8
|
+
class Catalog
|
|
9
|
+
MAX_CATALOG_BYTES = 25 * 1024 * 1024 # :nodoc:
|
|
10
|
+
|
|
11
|
+
# Refreshes normalized facts from the public models.dev catalog.
|
|
12
|
+
class ModelsDevSource < Source
|
|
13
|
+
URL = URI("https://models.dev/api.json") # :nodoc:
|
|
14
|
+
NAMESPACES = { # :nodoc:
|
|
15
|
+
"openai" => "openai", "openrouter" => "openrouter", "anthropic" => "anthropic",
|
|
16
|
+
"gemini" => "google", "vertex_ai" => "google-vertex", "bedrock" => "amazon-bedrock"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Creates a source that maps application provider names to adapters.
|
|
20
|
+
def initialize(provider_adapters:)
|
|
21
|
+
super(name: "models.dev")
|
|
22
|
+
@provider_adapters = provider_adapters.to_h.transform_keys(&:to_s)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Fetches normalized model facts, optionally for one canonical +target+.
|
|
26
|
+
def refresh(target: nil)
|
|
27
|
+
document = JSON.parse(
|
|
28
|
+
Support::HTTPClient.new(open_timeout: 5, read_timeout: 30, max_response_bytes: MAX_CATALOG_BYTES)
|
|
29
|
+
.request(uri: URL)
|
|
30
|
+
)
|
|
31
|
+
providers = target ? [target.provider] : @provider_adapters.keys
|
|
32
|
+
providers.each_with_object({}) do |provider, result|
|
|
33
|
+
namespace = NAMESPACES[@provider_adapters[provider]]
|
|
34
|
+
next unless namespace && document[namespace]
|
|
35
|
+
|
|
36
|
+
models = document.fetch(namespace).fetch("models")
|
|
37
|
+
selected = target ? models.slice(target.model_id) : models
|
|
38
|
+
selected.each { |id, value| result["#{provider}:#{id}"] = normalize(value) }
|
|
39
|
+
end
|
|
40
|
+
rescue JSON::ParserError, KeyError => error
|
|
41
|
+
raise ProviderError, "models.dev returned an invalid catalog: #{error.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def normalize(value)
|
|
47
|
+
parameters = []
|
|
48
|
+
parameters << "tools" if value["tool_call"]
|
|
49
|
+
parameters << "structured_outputs" if value["structured_output"]
|
|
50
|
+
parameters << "temperature" if value["temperature"]
|
|
51
|
+
parameters << "reasoning" if value["reasoning"]
|
|
52
|
+
{
|
|
53
|
+
context_window: value.dig("limit", "context"), max_output_tokens: value.dig("limit", "output"),
|
|
54
|
+
input_modalities: value.dig("modalities", "input"), output_modalities: value.dig("modalities", "output"),
|
|
55
|
+
supported_parameters: parameters.empty? ? nil : parameters, pricing: value["cost"],
|
|
56
|
+
observed_at: value["last_updated"] || value["release_date"]
|
|
57
|
+
}.compact
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Models
|
|
5
|
+
class Catalog
|
|
6
|
+
# Interface for catalog refresh implementations.
|
|
7
|
+
class Source
|
|
8
|
+
DEFAULT_ATTRIBUTE_MERGE_STRATEGIES = {}.freeze # :nodoc:
|
|
9
|
+
|
|
10
|
+
# Stable provenance name recorded for refreshed facts.
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
# Creates a source with its provenance +name+.
|
|
14
|
+
def initialize(name:)
|
|
15
|
+
@name = name.to_s.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns exceptional attribute merge strategies for records from this
|
|
19
|
+
# source. Attributes replace older values unless an array is mapped to
|
|
20
|
+
# +:union+.
|
|
21
|
+
def attribute_merge_strategies = DEFAULT_ATTRIBUTE_MERGE_STRATEGIES
|
|
22
|
+
|
|
23
|
+
# Returns normalized model records, optionally scoped to +target+.
|
|
24
|
+
def refresh(target: nil)
|
|
25
|
+
raise AbstractMethodError, "#{self.class} must implement #refresh"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|