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
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
# Getting Started with LittleGhost
|
|
2
|
-
|
|
3
|
-
This guide builds a small AI customer support feature around an agent that checks an in-memory help center and returns a normal Ruby result. By the end, you will have configured a model provider, declared a validated tool, run `CustomerSupportAgent`, and streamed the same request as events.
|
|
4
|
-
|
|
5
|
-
The example uses OpenAI, but the agent and tool do not depend on that choice. A `ModelRegistry` keeps the provider boundary behind the logical model role `customer_support`.
|
|
6
|
-
|
|
7
|
-
## Before you begin
|
|
8
|
-
|
|
9
|
-
LittleGhost requires Ruby 3.3 or newer. Add the gem to your `Gemfile`:
|
|
10
|
-
|
|
11
|
-
```ruby
|
|
12
|
-
gem "little_ghost"
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
Install the bundle and provide the credential used by this example:
|
|
16
|
-
|
|
17
|
-
```sh
|
|
18
|
-
$ bundle install
|
|
19
|
-
$ export OPENAI_API_KEY="..."
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Use application-specific secret management outside a local shell. Do not commit provider credentials.
|
|
23
|
-
|
|
24
|
-
## Create the application shape
|
|
25
|
-
|
|
26
|
-
LittleGhost looks for agents, tools, prompts, and skills under conventional application directories. This example needs four files:
|
|
27
|
-
|
|
28
|
-
```text
|
|
29
|
-
customer_support_app/
|
|
30
|
-
├── app/
|
|
31
|
-
│ ├── agents/
|
|
32
|
-
│ │ ├── research_agent.rb
|
|
33
|
-
│ │ └── customer_support_agent.rb
|
|
34
|
-
│ └── tools/
|
|
35
|
-
│ └── help_center_lookup_tool.rb
|
|
36
|
-
└── config/
|
|
37
|
-
└── little_ghost.rb
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Run the application from `customer_support_app/`, or set the configuration root explicitly before the first runtime is built. The runtime loads `config/little_ghost.rb` lazily and eager-loads conventional application code.
|
|
41
|
-
|
|
42
|
-
## Configure a logical model role
|
|
43
|
-
|
|
44
|
-
Define `CustomerSupportModels` in `config/little_ghost.rb` and register it with LittleGhost:
|
|
45
|
-
|
|
46
|
-
```ruby
|
|
47
|
-
require "little_ghost"
|
|
48
|
-
|
|
49
|
-
class CustomerSupportModels < LittleGhost::ModelRegistry
|
|
50
|
-
def initialize
|
|
51
|
-
super
|
|
52
|
-
|
|
53
|
-
provider(:openai) do |model:, **|
|
|
54
|
-
LittleGhost::Providers::OpenAI.new(
|
|
55
|
-
api_key: ENV.fetch("OPENAI_API_KEY"),
|
|
56
|
-
model:
|
|
57
|
-
)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
profile "customer_support",
|
|
61
|
-
provider: :openai,
|
|
62
|
-
model: "gpt-5",
|
|
63
|
-
settings: {temperature: 0.2}
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
LittleGhost.configure do |config|
|
|
68
|
-
config.models CustomerSupportModels
|
|
69
|
-
end
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
The agent will ask for the role `customer_support`; it never needs the provider name or provider model identifier. This separation lets an application change providers or override a profile for one invocation without changing agent classes. Treat model settings and profile overrides as trusted application configuration: construct or allowlist them server-side instead of copying unchecked request fields.
|
|
73
|
-
|
|
74
|
-
## Give the agent one validated tool
|
|
75
|
-
|
|
76
|
-
Create `app/tools/help_center_lookup_tool.rb`:
|
|
77
|
-
|
|
78
|
-
```ruby
|
|
79
|
-
class HelpCenterLookupTool < LittleGhost::Tool
|
|
80
|
-
HELP_CENTER_ENTRIES = {
|
|
81
|
-
"refunds" => "Refunds are available within 30 days of purchase.",
|
|
82
|
-
"shipping" => "Standard shipping takes three to five business days."
|
|
83
|
-
}.freeze
|
|
84
|
-
|
|
85
|
-
description "Look up a help center entry by topic."
|
|
86
|
-
input_schema(
|
|
87
|
-
type: "object",
|
|
88
|
-
properties: {
|
|
89
|
-
topic: {type: "string", enum: HELP_CENTER_ENTRIES.keys}
|
|
90
|
-
},
|
|
91
|
-
required: ["topic"],
|
|
92
|
-
additionalProperties: false
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
def call(input)
|
|
96
|
-
HELP_CENTER_ENTRIES.fetch(input.fetch("topic"))
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
A tool exposes a name, description, JSON input schema, and implementation. `HelpCenterLookupTool` gets its default model-visible name from the class name. LittleGhost validates input before calling `#call` and turns the returned value into text for model context.
|
|
102
|
-
|
|
103
|
-
This boundary is also the right place for application authorization. If lookup behavior or results depended on an account or tenant, `#call` would verify trusted run context before reading them. A schema validates shape; it does not authorize access.
|
|
104
|
-
|
|
105
|
-
## Define the agents
|
|
106
|
-
|
|
107
|
-
Create `app/agents/research_agent.rb`:
|
|
108
|
-
|
|
109
|
-
```ruby
|
|
110
|
-
class ResearchAgent < LittleGhost::Agent
|
|
111
|
-
description "Investigates support questions that need broader research."
|
|
112
|
-
model "customer_support.research"
|
|
113
|
-
system_prompt "Research the question and return a concise evidence summary."
|
|
114
|
-
end
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
There is no separate `customer_support.research` profile yet. Dotted roles fall back to the nearest registered parent, so this role resolves through `customer_support`. You can add a specialized profile later without changing `ResearchAgent`.
|
|
118
|
-
|
|
119
|
-
Now create `app/agents/customer_support_agent.rb`:
|
|
120
|
-
|
|
121
|
-
```ruby
|
|
122
|
-
class CustomerSupportAgent < LittleGhost::Agent
|
|
123
|
-
description "Answers customer support questions."
|
|
124
|
-
model "customer_support"
|
|
125
|
-
system_prompt <<~PROMPT
|
|
126
|
-
Answer clearly and do not invent company guidance.
|
|
127
|
-
Use the help center lookup tool before stating company guidance.
|
|
128
|
-
Delegate open-ended investigation to the research agent.
|
|
129
|
-
PROMPT
|
|
130
|
-
|
|
131
|
-
tools HelpCenterLookupTool
|
|
132
|
-
subagent ResearchAgent, kind: "research"
|
|
133
|
-
end
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
The class is a readable behavior boundary. It declares which model role, prompt, tools, and subagent capabilities can participate in a `CustomerSupportAgent` run. Declaring `ResearchAgent` as a subagent gives the model tools for delegation, with the turn, concurrency, and depth limits configured by the surrounding system; it does not force every request through research.
|
|
137
|
-
|
|
138
|
-
## Run the agent
|
|
139
|
-
|
|
140
|
-
From the application root, start a Ruby process that requires LittleGhost and asks the agent a question:
|
|
141
|
-
|
|
142
|
-
```ruby
|
|
143
|
-
require "little_ghost"
|
|
144
|
-
|
|
145
|
-
runtime = LittleGhost::Runtime.new(configuration: LittleGhost.configuration)
|
|
146
|
-
customer_support_agent = CustomerSupportAgent.new(runtime:)
|
|
147
|
-
run = customer_support_agent.ask(
|
|
148
|
-
"I bought an item two weeks ago. Can I get a refund?"
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
if run.completed?
|
|
152
|
-
puts run.response
|
|
153
|
-
else
|
|
154
|
-
warn "Support request ended as #{run.outcome}: #{run.error&.class}"
|
|
155
|
-
end
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
`#ask` consumes the invocation and returns its owning `LittleGhost::Run`. A successful run exposes the final text through `#response` and the normalized terminal value through `#result`. It also retains outcome, usage, messages, and any terminal error.
|
|
159
|
-
|
|
160
|
-
The model can call `HelpCenterLookupTool` with `{"topic":"refunds"}` and answer along these lines:
|
|
161
|
-
|
|
162
|
-
```text
|
|
163
|
-
Refunds are available within 30 days, so a purchase from two weeks ago is eligible.
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
Model wording is not deterministic. The important outcome is that the refund guidance comes from the validated tool result rather than from an unverified guess.
|
|
167
|
-
|
|
168
|
-
## Stream the response
|
|
169
|
-
|
|
170
|
-
Use `#stream_ask` when a console, web response, or UI should receive progress before the run completes:
|
|
171
|
-
|
|
172
|
-
```ruby
|
|
173
|
-
CustomerSupportAgent.new(runtime:).stream_ask("Can I get a refund?").each do |event|
|
|
174
|
-
case event.type
|
|
175
|
-
when :text_delta
|
|
176
|
-
print event.data.fetch(:text)
|
|
177
|
-
when :run_error
|
|
178
|
-
warn event.data.fetch(:message)
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
The enumerator yields `LittleGhost::StreamEvent` objects. Text, tool activity, usage, traces, subagent activity, and terminal lifecycle facts share that interface, so adapters can translate one framework stream into a console, HTTP stream, or AG-UI transport.
|
|
184
|
-
|
|
185
|
-
## Where to go next
|
|
186
|
-
|
|
187
|
-
Read [Core Concepts](Core%20Concepts.md) for the distinction between model-directed subagents and application-directed workflows, how sessions and runs divide ownership, and when `ResponseWorkflow` is a better fit than delegation. The API reference covers exact signatures for `LittleGhost::Agent`, `LittleGhost::Tool`, `LittleGhost::ModelRegistry`, and `LittleGhost::Run`.
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LittleGhost
|
|
4
|
-
# Supplies LittleGhost's ready-to-use model selection. The registry maps the
|
|
5
|
-
# +default+ role to GPT-5.6 Terra on OpenRouter or OpenAI and keeps the selected
|
|
6
|
-
# provider stable for its runtime.
|
|
7
|
-
#
|
|
8
|
-
# Prefixed credentials take precedence over conventional provider variables:
|
|
9
|
-
#
|
|
10
|
-
# 1. +LITTLEGHOST_OPENROUTER_API_KEY+
|
|
11
|
-
# 2. +LITTLEGHOST_OPENAI_API_KEY+
|
|
12
|
-
# 3. +OPENROUTER_API_KEY+
|
|
13
|
-
# 4. +OPENAI_API_KEY+
|
|
14
|
-
#
|
|
15
|
-
# Blank values are ignored. Resolving the +default+ role requires one supported
|
|
16
|
-
# key and raises ConfigurationError with the available choices otherwise. The
|
|
17
|
-
# environment is read when the registry is created, so build a new Runtime
|
|
18
|
-
# after changing credentials. Configure a ModelRegistry explicitly when the
|
|
19
|
-
# application needs another provider, model, or logical role.
|
|
20
|
-
#
|
|
21
|
-
# *Warning:* Setting any supported key authorizes LittleGhost to send model
|
|
22
|
-
# inputs, including prompts, conversation history, tool data, and attachments,
|
|
23
|
-
# to the selected external provider. When more than one key is present, the
|
|
24
|
-
# precedence above determines that destination. Applications with provider or
|
|
25
|
-
# data-residency requirements should configure a ModelRegistry explicitly.
|
|
26
|
-
class DefaultModelRegistry < ModelRegistry
|
|
27
|
-
CREDENTIALS = [
|
|
28
|
-
["LITTLEGHOST_OPENROUTER_API_KEY", :openrouter],
|
|
29
|
-
["LITTLEGHOST_OPENAI_API_KEY", :openai],
|
|
30
|
-
["OPENROUTER_API_KEY", :openrouter],
|
|
31
|
-
["OPENAI_API_KEY", :openai]
|
|
32
|
-
].freeze # :nodoc:
|
|
33
|
-
MODELS = {
|
|
34
|
-
openrouter: "openai/gpt-5.6-terra",
|
|
35
|
-
openai: "gpt-5.6-terra"
|
|
36
|
-
}.freeze # :nodoc:
|
|
37
|
-
PROVIDERS = {
|
|
38
|
-
openrouter: Providers::OpenRouter,
|
|
39
|
-
openai: Providers::OpenAI
|
|
40
|
-
}.freeze # :nodoc:
|
|
41
|
-
MISSING_CREDENTIAL_MESSAGE =
|
|
42
|
-
"No API key is configured for the default model. Set " \
|
|
43
|
-
"LITTLEGHOST_OPENROUTER_API_KEY, LITTLEGHOST_OPENAI_API_KEY, " \
|
|
44
|
-
"OPENROUTER_API_KEY, or OPENAI_API_KEY, or configure a custom " \
|
|
45
|
-
"model registry with LittleGhost.configure." # :nodoc:
|
|
46
|
-
|
|
47
|
-
# Selects one provider from the current environment and registers the
|
|
48
|
-
# +default+ profile. The selection remains fixed for this registry instance.
|
|
49
|
-
def initialize
|
|
50
|
-
super
|
|
51
|
-
variable, provider_name = CREDENTIALS.find { |name, _provider| !ENV[name].to_s.strip.empty? }
|
|
52
|
-
if variable
|
|
53
|
-
api_key = ENV.fetch(variable)
|
|
54
|
-
provider_class = PROVIDERS.fetch(provider_name)
|
|
55
|
-
provider(provider_name) do |model:, **|
|
|
56
|
-
provider_class.new(api_key:, model:)
|
|
57
|
-
end
|
|
58
|
-
profile("default", provider: provider_name, model: MODELS.fetch(provider_name))
|
|
59
|
-
else
|
|
60
|
-
configure_missing_credential
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
def configure_missing_credential
|
|
67
|
-
provider(:unconfigured) { |**| raise ConfigurationError, MISSING_CREDENTIAL_MESSAGE }
|
|
68
|
-
profile("default", provider: :unconfigured, model: MODELS.fetch(:openai))
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LittleGhost
|
|
4
|
-
# Give application roles stable model choices without coupling agents to vendors.
|
|
5
|
-
# A registry joins provider factories with named, inheritable profiles.
|
|
6
|
-
#
|
|
7
|
-
# A support application can keep its general and research agents on related
|
|
8
|
-
# profiles while selecting one provider in one place:
|
|
9
|
-
#
|
|
10
|
-
# class CustomerSupportModels < LittleGhost::ModelRegistry
|
|
11
|
-
# def initialize
|
|
12
|
-
# super
|
|
13
|
-
# provider(:openai) do |model:, **|
|
|
14
|
-
# LittleGhost::Providers::OpenAI.new(
|
|
15
|
-
# api_key: ENV.fetch("OPENAI_API_KEY"), model: model
|
|
16
|
-
# )
|
|
17
|
-
# end
|
|
18
|
-
# profile "customer_support", provider: :openai, model: "gpt-5"
|
|
19
|
-
# profile "customer_support.research", inherit: "customer_support",
|
|
20
|
-
# settings: {temperature: 0.1}
|
|
21
|
-
# end
|
|
22
|
-
# end
|
|
23
|
-
#
|
|
24
|
-
# model = CustomerSupportModels.new.resolve("customer_support.research")
|
|
25
|
-
# model.id # => "gpt-5"
|
|
26
|
-
# model.settings # => {temperature: 0.1}
|
|
27
|
-
#
|
|
28
|
-
# Dotted roles resolve from the exact role toward shorter registered parents.
|
|
29
|
-
# Explicit profile inheritance supplies provider, model, settings, and metadata;
|
|
30
|
-
# per-invocation overrides then layer from inherited parents through the exact
|
|
31
|
-
# requested role. A final +override+ passed to +resolve+ wins over both.
|
|
32
|
-
# Settings and overrides are control-plane input: construct or allowlist them
|
|
33
|
-
# in application code rather than copying unchecked request fields. An
|
|
34
|
-
# override can select a different registered provider or model and change the
|
|
35
|
-
# destination, capability, and cost of a request.
|
|
36
|
-
#
|
|
37
|
-
# Provider factories receive the resolved identity, settings, metadata,
|
|
38
|
-
# invocation, run context, and forwarding options. Resolution raises
|
|
39
|
-
# ConfigurationError for missing roles, providers, models, factories, or
|
|
40
|
-
# circular inheritance; exceptions raised inside a factory are not masked.
|
|
41
|
-
class ModelRegistry
|
|
42
|
-
# Starts an empty registry ready for provider factories and model profiles.
|
|
43
|
-
def initialize
|
|
44
|
-
@providers = {}
|
|
45
|
-
@profiles = {}
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Associates +name+ with a provider factory and returns +self+.
|
|
49
|
-
#
|
|
50
|
-
# The factory receives the resolved +model+, logical +role+, profile
|
|
51
|
-
# +settings+ and +metadata+, invocation, run context, and resolution options.
|
|
52
|
-
def provider(name, callable = nil, &factory)
|
|
53
|
-
@providers[name.to_sym] = factory || callable || raise(ArgumentError, "provider factory is required")
|
|
54
|
-
self
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Adds an inheritable model profile named +name+ and returns +self+.
|
|
58
|
-
#
|
|
59
|
-
# Parent settings and metadata merge into the child. A child may inherit its
|
|
60
|
-
# provider and model or replace either one.
|
|
61
|
-
def profile(name, provider: nil, model: nil, settings: {}, metadata: {}, inherit: nil)
|
|
62
|
-
@profiles[name.to_s] = {
|
|
63
|
-
provider: provider&.to_sym,
|
|
64
|
-
model: model&.to_s,
|
|
65
|
-
settings: settings.to_h.transform_keys(&:to_sym),
|
|
66
|
-
metadata: metadata.to_h,
|
|
67
|
-
inherit: inherit&.to_s
|
|
68
|
-
}
|
|
69
|
-
self
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Materializes +name+ as a configured Model for the current invocation.
|
|
73
|
-
#
|
|
74
|
-
# +override+ applies after registered profiles and invocation profile
|
|
75
|
-
# overrides. +context+ takes precedence over the legacy +run+ argument when
|
|
76
|
-
# passed to the provider factory.
|
|
77
|
-
def resolve(name, invocation: nil, override: nil, run: nil, context: nil, **options)
|
|
78
|
-
role = name.to_s
|
|
79
|
-
profile_name = profile_for(role)
|
|
80
|
-
configuration = resolved_profile(profile_name)
|
|
81
|
-
override_profiles(profile_name, role).each do |profile|
|
|
82
|
-
configuration = merge(configuration, profile_override(invocation, profile))
|
|
83
|
-
end
|
|
84
|
-
configuration = merge(configuration, override)
|
|
85
|
-
provider_name = configuration[:provider]
|
|
86
|
-
model_id = configuration[:model]
|
|
87
|
-
raise ConfigurationError, "Model profile #{profile_name} does not define a provider" unless provider_name
|
|
88
|
-
raise ConfigurationError, "Model profile #{profile_name} does not define a model" unless model_id
|
|
89
|
-
|
|
90
|
-
factory = @providers.fetch(provider_name) do
|
|
91
|
-
raise ConfigurationError, "No provider is registered for #{provider_name}"
|
|
92
|
-
end
|
|
93
|
-
provider = factory.call(
|
|
94
|
-
model: model_id,
|
|
95
|
-
role:,
|
|
96
|
-
settings: configuration.fetch(:settings),
|
|
97
|
-
metadata: configuration.fetch(:metadata),
|
|
98
|
-
invocation:,
|
|
99
|
-
context: context || run,
|
|
100
|
-
**options
|
|
101
|
-
)
|
|
102
|
-
Model.new(
|
|
103
|
-
provider:,
|
|
104
|
-
provider_name:,
|
|
105
|
-
model: model_id,
|
|
106
|
-
settings: configuration.fetch(:settings),
|
|
107
|
-
metadata: configuration.fetch(:metadata),
|
|
108
|
-
role:
|
|
109
|
-
)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
private
|
|
113
|
-
|
|
114
|
-
def merge(configuration, override)
|
|
115
|
-
values = (override || {}).to_h.transform_keys(&:to_sym)
|
|
116
|
-
settings = values.fetch(:settings, values.fetch(:parameters, {})).to_h.transform_keys(&:to_sym)
|
|
117
|
-
configuration
|
|
118
|
-
.merge(values.except(:settings, :parameters, :model_id))
|
|
119
|
-
.merge(
|
|
120
|
-
provider: values.key?(:provider) ? values[:provider]&.to_sym : configuration[:provider],
|
|
121
|
-
model: values.fetch(:model, values.fetch(:model_id, configuration[:model])),
|
|
122
|
-
settings: configuration.fetch(:settings).merge(settings),
|
|
123
|
-
metadata: configuration.fetch(:metadata).merge(values.fetch(:metadata, {}))
|
|
124
|
-
)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def profile_for(role)
|
|
128
|
-
parts = role.to_s.split(".")
|
|
129
|
-
until parts.empty?
|
|
130
|
-
candidate = parts.join(".")
|
|
131
|
-
return candidate if @profiles.key?(candidate)
|
|
132
|
-
|
|
133
|
-
parts.pop
|
|
134
|
-
end
|
|
135
|
-
raise ConfigurationError, "Unknown model profile: #{role}"
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def resolved_profile(name, seen = [])
|
|
139
|
-
raise ConfigurationError, "Circular model profile inheritance: #{[*seen, name].join(" -> ")}" if seen.include?(name)
|
|
140
|
-
|
|
141
|
-
profile = @profiles.fetch(name) do
|
|
142
|
-
raise ConfigurationError, "Unknown model profile: #{name}"
|
|
143
|
-
end
|
|
144
|
-
parent_name = profile.fetch(:inherit)
|
|
145
|
-
return profile unless parent_name
|
|
146
|
-
|
|
147
|
-
parent = resolved_profile(parent_name, [*seen, name])
|
|
148
|
-
{
|
|
149
|
-
provider: profile.fetch(:provider) || parent.fetch(:provider),
|
|
150
|
-
model: profile.fetch(:model) || parent.fetch(:model),
|
|
151
|
-
settings: parent.fetch(:settings).merge(profile.fetch(:settings)),
|
|
152
|
-
metadata: parent.fetch(:metadata).merge(profile.fetch(:metadata)),
|
|
153
|
-
inherit: parent_name
|
|
154
|
-
}
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def override_profiles(profile_name, role)
|
|
158
|
-
names = []
|
|
159
|
-
current = profile_name
|
|
160
|
-
while current
|
|
161
|
-
names.unshift(current)
|
|
162
|
-
current = @profiles.fetch(current).fetch(:inherit)
|
|
163
|
-
end
|
|
164
|
-
names << role unless names.include?(role)
|
|
165
|
-
names
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def profile_override(invocation, role)
|
|
169
|
-
profiles = invocation&.model_profiles || {}
|
|
170
|
-
profiles[role.to_s] || {}
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
end
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "net/http"
|
|
4
|
-
require "openssl"
|
|
5
|
-
require "uri"
|
|
6
|
-
|
|
7
|
-
module LittleGhost
|
|
8
|
-
module Providers
|
|
9
|
-
# Reports an HTTP or network failure from a provider transport.
|
|
10
|
-
class HTTPError < ProviderError
|
|
11
|
-
# HTTP status and bounded response body, when the server supplied them.
|
|
12
|
-
attr_reader :status, :body
|
|
13
|
-
|
|
14
|
-
# Creates an error with optional response details.
|
|
15
|
-
def initialize(message, status: nil, body: nil)
|
|
16
|
-
@status = status
|
|
17
|
-
@body = body
|
|
18
|
-
super(message)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Indicates whether a provider client may retry the request.
|
|
22
|
-
def retryable?
|
|
23
|
-
status.nil? || status == 408 || status == 409 || status == 429 || status >= 500
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# HTTPTransport gives provider clients a shared, bounded streaming HTTP layer.
|
|
28
|
-
# It applies cancellation, deadlines, timeouts, and response-size limits while
|
|
29
|
-
# yielding response chunks as they arrive.
|
|
30
|
-
#
|
|
31
|
-
# === Security and trust
|
|
32
|
-
#
|
|
33
|
-
# HTTPS is required by default. Enabling +allow_insecure_http+ can expose API
|
|
34
|
-
# keys and model content in transit; use it only with a trusted local
|
|
35
|
-
# development endpoint.
|
|
36
|
-
class HTTPTransport
|
|
37
|
-
# Default upper bound for a complete provider response (50 MiB).
|
|
38
|
-
DEFAULT_MAX_RESPONSE_BYTES = 50 * 1024 * 1024
|
|
39
|
-
DEFAULT_MAX_ERROR_BODY_BYTES = 4 * 1024 # :nodoc:
|
|
40
|
-
TRANSIENT_NETWORK_ERRORS = [
|
|
41
|
-
Net::OpenTimeout,
|
|
42
|
-
Net::ReadTimeout,
|
|
43
|
-
Net::WriteTimeout,
|
|
44
|
-
EOFError,
|
|
45
|
-
SocketError,
|
|
46
|
-
SystemCallError,
|
|
47
|
-
IOError,
|
|
48
|
-
OpenSSL::SSL::SSLError,
|
|
49
|
-
Net::ProtocolError,
|
|
50
|
-
Net::HTTPBadResponse,
|
|
51
|
-
Net::HTTPHeaderSyntaxError
|
|
52
|
-
].freeze # :nodoc:
|
|
53
|
-
|
|
54
|
-
# Configures +base_url+ with connection, read, and response
|
|
55
|
-
# size limits.
|
|
56
|
-
def initialize(
|
|
57
|
-
base_url:,
|
|
58
|
-
open_timeout:,
|
|
59
|
-
read_timeout:,
|
|
60
|
-
allow_insecure_http: false,
|
|
61
|
-
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
|
|
62
|
-
max_error_body_bytes: DEFAULT_MAX_ERROR_BODY_BYTES
|
|
63
|
-
)
|
|
64
|
-
@base_url = URI(base_url.end_with?("/") ? base_url : "#{base_url}/")
|
|
65
|
-
unless %w[http https].include?(@base_url.scheme) && @base_url.host
|
|
66
|
-
raise ConfigurationError, "Provider base_url must be an HTTP(S) URL"
|
|
67
|
-
end
|
|
68
|
-
if @base_url.scheme == "http" && !allow_insecure_http
|
|
69
|
-
raise ConfigurationError, "Provider base_url must use HTTPS unless allow_insecure_http is enabled"
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
@open_timeout = open_timeout
|
|
73
|
-
@read_timeout = read_timeout
|
|
74
|
-
@max_response_bytes = positive_integer(max_response_bytes, :max_response_bytes)
|
|
75
|
-
@max_error_body_bytes = positive_integer(max_error_body_bytes, :max_error_body_bytes)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Posts +body+ and yields response chunks until completion.
|
|
79
|
-
#
|
|
80
|
-
# Cancellation and +deadline+ interrupt the request. Without a block, this
|
|
81
|
-
# method returns an Enumerator.
|
|
82
|
-
def stream(path:, headers:, body:, cancellation_token:, deadline: nil)
|
|
83
|
-
return enum_for(__method__, path:, headers:, body:, cancellation_token:, deadline:) unless block_given?
|
|
84
|
-
|
|
85
|
-
stream = Support::InterruptibleStream.new(cancellation_token:, deadline:) do |emit|
|
|
86
|
-
uri = URI.join(@base_url.to_s, path.sub(%r{\A/}, ""))
|
|
87
|
-
request = Net::HTTP::Post.new(uri)
|
|
88
|
-
headers.each { |name, value| request[name] = value }
|
|
89
|
-
request.body = body
|
|
90
|
-
|
|
91
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
92
|
-
http.use_ssl = uri.scheme == "https"
|
|
93
|
-
http.open_timeout = remaining_timeout(deadline, @open_timeout)
|
|
94
|
-
http.read_timeout = remaining_timeout(deadline, @read_timeout)
|
|
95
|
-
http.write_timeout = remaining_timeout(deadline, @read_timeout)
|
|
96
|
-
|
|
97
|
-
http.request(request) do |response|
|
|
98
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
99
|
-
response_body = read_limited(response, @max_error_body_bytes)
|
|
100
|
-
raise HTTPError.new(
|
|
101
|
-
"Provider request failed with HTTP #{response.code}",
|
|
102
|
-
status: response.code.to_i,
|
|
103
|
-
body: response_body
|
|
104
|
-
)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
bytes_read = 0
|
|
108
|
-
response.read_body do |chunk|
|
|
109
|
-
bytes_read += chunk.bytesize
|
|
110
|
-
raise ProtocolError, "Provider response exceeded #{@max_response_bytes} bytes" if bytes_read > @max_response_bytes
|
|
111
|
-
|
|
112
|
-
emit.call(chunk)
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
rescue *TRANSIENT_NETWORK_ERRORS => error
|
|
116
|
-
raise HTTPError, "Provider request failed (#{error.class})"
|
|
117
|
-
end
|
|
118
|
-
stream.each { |chunk| yield chunk }
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
private
|
|
122
|
-
|
|
123
|
-
def remaining_timeout(deadline, maximum)
|
|
124
|
-
return maximum unless deadline
|
|
125
|
-
|
|
126
|
-
remaining = deadline - Time.now
|
|
127
|
-
raise DeadlineExceededError, "The run deadline was reached" unless remaining.positive?
|
|
128
|
-
|
|
129
|
-
[remaining, maximum].min
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def read_limited(response, limit)
|
|
133
|
-
body = +""
|
|
134
|
-
response.read_body do |chunk|
|
|
135
|
-
remaining = limit - body.bytesize
|
|
136
|
-
body << chunk.byteslice(0, remaining) if remaining.positive?
|
|
137
|
-
end
|
|
138
|
-
body
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def positive_integer(value, name)
|
|
142
|
-
integer = Integer(value)
|
|
143
|
-
raise ArgumentError, "#{name} must be positive" unless integer.positive?
|
|
144
|
-
|
|
145
|
-
integer
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|