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
@@ -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