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
@@ -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 model_profiles
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=
@@ -1,84 +1,104 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LittleGhost
4
- # Model is the configured connection between an agent role and a provider. It
5
- # keeps provider choice and defaults out of the agent class that uses them.
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
- IDENTITY_METADATA_KEYS = %w[provider model_id model_role].freeze # :nodoc:
26
+ include ModelInterface
12
27
 
13
- # Provider object and name, provider model ID, default settings, normalized
14
- # metadata, and logical application role.
15
- attr_reader :provider, :provider_name, :id, :settings, :metadata, :role
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
- # Wraps an object that responds to +stream+.
18
- def initialize(provider:, provider_name:, id: nil, model: nil, settings: {}, metadata: {}, role: nil)
19
- raise ArgumentError, "provider must respond to stream" unless provider.respond_to?(:stream)
20
- raise ArgumentError, "provider_name is required" if provider_name.nil? || provider_name.to_s.empty?
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
- @provider_name = provider_name.to_sym
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
- profile_metadata = metadata.to_h.reject { |key, _value| IDENTITY_METADATA_KEYS.include?(key.to_s) }
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: settings.merge(request.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
- if provider.respond_to?(:prepare_request)
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, falling back to the permissive legacy
58
- # contract for providers that do not advertise them.
79
+ # Uses advertised provider capabilities.
59
80
  def capabilities
60
- @capabilities ||= if provider.respond_to?(: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 = metadata[:input_modalities] || metadata["input_modalities"]
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
- missing = required - Array(supported).map { |value| value.to_s.downcase }
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 the backwards-compatible capability set for legacy providers.
49
- def self.legacy
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: legacy
119
- # Supplies the permissive capability set for providers that predate explicit
120
- # capability reporting.
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