smith-agents 0.12.3 → 0.12.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a97b202bc606c58889517b16ffe906f8775d003b9386d371b63ea08dd725dff
4
- data.tar.gz: 5ad614925043a70182362086dd2c03e9d3cc3bd6eea645b8d04b4e2d61b93719
3
+ metadata.gz: 663a12c4a26041ded1da57a8bd899213cc28d330eae1d42a9dc3e4fdbda4dd87
4
+ data.tar.gz: c997927bbcea376c0db21a92aacdc21e510ac3c8e664d4ebb1eb0f8282cd2b32
5
5
  SHA512:
6
- metadata.gz: eb8e78bc96e382495c098bbb2f5e794a8efe674d5c662715d18f6d6b1b2445d2ba69198b6e447849f509f5cc58fc06ed001162f18829c485b3a1456eb843cfe0
7
- data.tar.gz: 6f6112a5048ab240fe3658b6107d5e2f950945045f4518e2f07f16a72e40cb1b91a2b0ea31278df09c4fde3329132bfd5cf7dceef76040f00d5d007e01ceebea
6
+ metadata.gz: 27cc5f8437f8325afde26cd1816f8ca007a1c0f43b0ea02cc14cdcd49c17504cc5b2a22934c8b26ddcac132f5d2dc28f40e9e6fcde8976e01b3d4438523235b2
7
+ data.tar.gz: d621799d104e1b3b1ba97f14974afb6fa01c99cd179f8817471a55148d0587c9b2e333d47a0ab016855e48b8ab7739badb5db72c22e6730294f23da4947bc96d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ All notable changes to Smith are documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Smith is pre-1.0 and under active development; expect occasional contract tightening between minor versions until 1.0.
6
6
 
7
+ ## [0.12.5] - 2026-10-13
8
+
9
+ ### Fixed
10
+
11
+ - A streamed step's requests are each sent once: its chat takes a copy of
12
+ the agent's config (its context, or RubyLLM's) with RubyLLM's HTTP retries
13
+ (`max_retries`) off, its model resolved again as RubyLLM resolves a chat
14
+ built with a context (the same id and provider, and the agent's own
15
+ `assume_model_exists`, so a model RubyLLM lists keeps its output limit and
16
+ thinking options). Retrying a stream read the next response's error into
17
+ the buffer the first had filled, so the provider's words were lost: a
18
+ refusal for want of credit reached Smith as a bare "Invalid request"
19
+ (`RubyLLM::BadRequestError`) and was never classified as
20
+ `RubyLLM::PaymentRequiredError`, and a spend-cap 429 as a plain rate limit.
21
+ A stream that failed part way was sent again, its text streamed twice.
22
+ Every provider failure of a streamed request, a transient one at connect
23
+ time included, now goes straight to the step's fallbacks and retries, so
24
+ a streamed step that should ride out an outage declares them. Calls that
25
+ do not stream, and requests OpenAI's Responses route sends whole, keep
26
+ RubyLLM's retries. The global config and the agent's are never changed.
27
+
28
+ ## [0.12.4] - 2026-10-13
29
+
30
+ ### Changed
31
+
32
+ - A provider's refusal for want of credit or past a spending limit is
33
+ `RubyLLM::PaymentRequiredError` whatever status it came with
34
+ (`Smith::BillingRefusals`), with the error RubyLLM raised as its cause. A
35
+ refusal is told by what the provider says it is: Anthropic's `billing_error`,
36
+ its tier's monthly spend cap (a 429 whose `error_code` is
37
+ `enforced_spend_limit_reached`), its 400s for spent credit ("Your credit
38
+ balance is too low") and for a spend limit the account set ("You have
39
+ reached your specified API usage limits"), and OpenAI's
40
+ `insufficient_quota`. A streamed request's refusal, which RubyLLM raises as
41
+ a `RubyLLM::ServerError`, is one too, and so is one met by
42
+ `Smith::Providers::Anthropic::Batches#submit`. It is the provider account's
43
+ failure, so another provider in the fallback chain is tried, and a host can
44
+ name `RubyLLM::PaymentRequiredError` in `interrupting_errors` to have a step
45
+ wait for credit instead of failing. Any other error is unchanged: a rate
46
+ limit that mentions a quota (Gemini's) stays a rate limit.
47
+ - A model candidate that met an interrupting error outlasts how a later
48
+ fallback fails: the step is cut short, as it would have been with no
49
+ fallback, instead of failing on the fallback's error.
50
+
7
51
  ## [0.12.3] - 2026-10-12
8
52
 
9
53
  ### Changed
data/README.md CHANGED
@@ -30,7 +30,7 @@ What you get:
30
30
 
31
31
  ```ruby
32
32
  # Gemfile
33
- gem "smith-agents", "0.12.3", require: "smith"
33
+ gem "smith-agents", "0.12.5", require: "smith"
34
34
  ```
35
35
 
36
36
  In a Rails app:
data/docs/AGENTS.md CHANGED
@@ -155,7 +155,17 @@ reason and message id, with `mode: :stream`), and a replayed step streams again
155
155
  (unless a response memo kept its reply: then its sink gets the text at once).
156
156
  Graph inspection shows the stream, and runtime readiness reports a missing sink
157
157
  (`:stream_sink_missing`) and an agent whose calls go to the Responses API
158
- (`:stream_whole_replies`).
158
+ (`:stream_whole_replies`). A streamed step's requests are each sent once:
159
+ its chat takes a copy of the agent's config with RubyLLM's HTTP retries
160
+ (`max_retries`) off and its model resolved again as RubyLLM resolves a chat
161
+ built with a context (a model RubyLLM lists keeps its limits), since a
162
+ retried stream loses the first response's error body (a refusal for want of
163
+ credit would reach Smith as a bare "Invalid request") or streams its text
164
+ again. Every provider failure of a streamed request, a transient one at
165
+ connect time included, takes the step's fallbacks and retries directly, so
166
+ declare them (`fallback_models`, `retry_on Smith::AgentError`) on a streamed
167
+ step that should ride out an outage. A request OpenAI's Responses route sends
168
+ whole keeps the retries.
159
169
 
160
170
  ## Batch mode
161
171
 
@@ -99,6 +99,24 @@ next advanced; a strict run is found under way on restore
99
99
  (`Smith::StepInProgressOnRestore`), as after a crash. The instance that
100
100
  raised is not advanced again: restore the run.
101
101
 
102
+ A provider's refusal for want of credit or past a spending limit is
103
+ `RubyLLM::PaymentRequiredError` whatever status it came with, its cause the
104
+ error RubyLLM raised. Smith tells one by what the provider says it is:
105
+ Anthropic's `billing_error` (402), its tier's monthly spend cap (a 429 whose
106
+ `error.details.error_code` is `enforced_spend_limit_reached`), its 400s for
107
+ spent credit ("Your credit balance is too low") and for a spend limit the
108
+ account set ("You have reached your specified API usage limits"), and
109
+ OpenAI's `insufficient_quota` (429). RubyLLM raises a streamed request's
110
+ refusal as a `RubyLLM::ServerError`; it is one all the same. It is the
111
+ provider account's, so another provider in the fallback chain is tried;
112
+ naming `RubyLLM::PaymentRequiredError` makes a step that meets it wait for
113
+ credit instead of failing, even when a fallback then fails some other way.
114
+ `Smith::Providers::Anthropic::Batches#submit` raises it too. Any other error
115
+ stays as it is: a rate limit whose message mentions a quota (Gemini's) is a
116
+ rate limit. RubyLLM's own HTTP retries (`max_retries`) may repeat a 429 of a
117
+ call that does not stream before Smith sees it (a refused request is not
118
+ charged); a streamed call is sent once, so its refusal reaches Smith whole.
119
+
102
120
  Name only errors that mean your own systems failed. A provider's own failure
103
121
  (`RubyLLM::Error`, or its HTTP client's `Faraday::Error`) is the provider's
104
122
  whatever it was raised from, so naming a low-level class such as
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module AttemptChat
6
+ private
7
+
8
+ # An attempt's chat. A streamed step's sends each request it streams
9
+ # over the wire once: RubyLLM's HTTP retries would send it again on an
10
+ # error, its error body already read into the first request's buffer
11
+ # (so a refusal for want of credit reaches Smith as a bare "Invalid
12
+ # request", never classified) or its text already streamed (the
13
+ # retry's text would join it). Smith's fallbacks and the step's
14
+ # retries take their place. Its chat takes a copy of the agent's config
15
+ # (its context, or RubyLLM's) without the retries, and resolves its
16
+ # model again as RubyLLM builds a chat with a context (the same id and
17
+ # provider, and the agent's own assume_model_exists, so a model RubyLLM
18
+ # knows keeps its limits), before any hold attaches to its provider. A
19
+ # request OpenAI's Responses route sends answers whole, unstreamed,
20
+ # and keeps the retries.
21
+ def attempt_chat(agent_class, model_reference)
22
+ chat = agent_class.chat(**model_reference.chat_options, **bridge_workflow_inputs(agent_class))
23
+ return chat unless @step_stream && streams_over_the_wire?(chat)
24
+
25
+ sent_once(chat, agent_class)
26
+ end
27
+
28
+ def sent_once(chat, agent_class)
29
+ model = chat.model
30
+ config = (agent_class.context&.config || RubyLLM.config).dup
31
+ config.max_retries = 0
32
+ chat.with_context(RubyLLM::Context.new(config))
33
+ chat.with_model(model.id, provider: model.provider,
34
+ assume_exists: agent_class.chat_kwargs.fetch(:assume_model_exists, false))
35
+ end
36
+
37
+ def streams_over_the_wire?(chat)
38
+ return false unless chat.respond_to?(:with_context) && chat.respond_to?(:with_model)
39
+
40
+ (chat.params[:openai_api_mode] || chat.params["openai_api_mode"]).to_s != "responses"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -123,7 +123,9 @@ module Smith
123
123
  # have their join target; the error then propagates unchanged. A
124
124
  # provider failure's own reported usage is accounted beside the
125
125
  # completed prefix, so its trace totals both.
126
+ # A billing refusal is recorded as the provider account's (BillingRefusals).
126
127
  def failed_provider_attempt(error, observed_reference, timer, facts, prefix_usages)
128
+ error = BillingRefusals.classified(error)
127
129
  aborted = !provider_failure?(error)
128
130
  usages = aborted ? prefix_usages : [*prefix_usages, ProviderUsage.from_message(error)].compact
129
131
  attempt = ProviderAttempt.failure(
@@ -34,18 +34,16 @@ module Smith
34
34
 
35
35
  def attempt_candidates(agent_class, prepared_input, chain, shape)
36
36
  candidates = ProviderCandidateSequence.new(chain)
37
+ interrupted = nil
37
38
  candidates.each do |model_reference, index|
38
39
  check_deadline! if index.positive?
39
40
  withdraw_streamed(index.positive? ? :fallback : :retry)
40
41
  attempt = attempt_model(agent_class, prepared_input, model_reference, shape:, attempt_index: index)
41
42
  return attempt if attempt.success?
42
43
 
44
+ interrupted ||= attempt if Interruptions.find(attempt.error)
43
45
  candidates.suppress(account_failed_provider(attempt, model_reference))
44
- handle_provider_failure!(
45
- attempt.error, attempt.model_reference, agent_class,
46
- fallback_available: candidates.fallback_available?,
47
- attempt_id: attempt.attempt_id
48
- )
46
+ fail_over!(attempt, agent_class, candidates, interrupted)
49
47
  end
50
48
 
51
49
  # Fail closed: an empty candidate chain (agent without a model)
@@ -55,6 +53,22 @@ module Smith
55
53
  "no executable model candidate for #{agent_class}; declare a model or fallback_models"
56
54
  end
57
55
 
56
+ # Returns when another candidate is left to try. A candidate that met
57
+ # an interrupting error (an account out of credit) outlasts how a later
58
+ # one fails: the step was cut short, not failed, and is taken again
59
+ # from its first candidate once the host can do its part.
60
+ def fail_over!(attempt, agent_class, candidates, interrupted)
61
+ handle_provider_failure!(
62
+ attempt.error, attempt.model_reference, agent_class,
63
+ fallback_available: candidates.fallback_available?,
64
+ attempt_id: attempt.attempt_id
65
+ )
66
+ rescue StandardError => e
67
+ raise if interrupted.nil? || Interruptions.find(e)
68
+
69
+ raise terminal_provider_error(interrupted.error, interrupted.model_reference), cause: interrupted.error
70
+ end
71
+
58
72
  # Suppression keys on the provider that actually rejected the
59
73
  # account; when the chat is unobservable the attempted reference's
60
74
  # declared provider is the best available attribution, so a dead
@@ -133,7 +147,7 @@ module Smith
133
147
  # rubocop:enable Metrics/AbcSize
134
148
 
135
149
  def prepared_attempt_chat(agent_class, prepared_input, model_reference, shape)
136
- chat = agent_class.chat(**model_reference.chat_options, **bridge_workflow_inputs(agent_class))
150
+ chat = attempt_chat(agent_class, model_reference)
137
151
  add_prepared_input(chat, prepared_input, observed_model_reference(chat, fallback: model_reference).provider)
138
152
  continue_paused_loop(chat)
139
153
  shaped_chat(chat, model_reference, shape)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "attempt_chat"
4
+
3
5
  module Smith
4
6
  class Agent
5
7
  # The provider calls of a streamed step (Smith::Streaming): before a
@@ -8,6 +10,8 @@ module Smith
8
10
  # reason is read from the stream (Providers::StreamFacts), as a
9
11
  # streamed reply keeps no raw body.
10
12
  module ProviderStreaming
13
+ include AttemptChat
14
+
11
15
  # Room for what a pause adds besides its messages (its retry, the
12
16
  # attempt's usage).
13
17
  PAUSE_MARGIN = 4096
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ruby_llm"
5
+
6
+ module Smith
7
+ # A provider's refusal for want of credit or past a spending limit, whatever
8
+ # status it came with. Smith takes each as RubyLLM::PaymentRequiredError,
9
+ # the error RubyLLM raises for a 402 (its cause the error RubyLLM raised),
10
+ # so the refusal is the provider account's (another provider in the
11
+ # fallback chain is tried) and a host can name that one class in
12
+ # interrupting_errors to have a step wait for credit instead of failing.
13
+ #
14
+ # A refusal is told by what the provider says it is, never by its status
15
+ # alone: Anthropic's billing_error (402), its tier's monthly spend cap (429,
16
+ # error_code enforced_spend_limit_reached), its spent credit and a spend
17
+ # limit the account set (400s known only by how their message begins), and
18
+ # OpenAI's insufficient_quota (429). A streamed request's refusal arrives
19
+ # as a RubyLLM::ServerError, since RubyLLM takes every streamed error as a
20
+ # 500. Any other error stays as it is: a rate limit that mentions a quota
21
+ # (Gemini's) is a rate limit.
22
+ module BillingRefusals
23
+ CLASSES = [RubyLLM::BadRequestError, RubyLLM::RateLimitError, RubyLLM::ServerError].freeze
24
+ TYPES = %w[billing_error insufficient_quota].freeze
25
+ CODES = %w[enforced_spend_limit_reached].freeze
26
+ MESSAGES = [
27
+ /\AYour credit balance is too low/i,
28
+ /\AYou have reached your specified (?:workspace )?API usage limits/i
29
+ ].freeze
30
+ private_constant :CLASSES, :TYPES, :CODES, :MESSAGES
31
+
32
+ module_function
33
+
34
+ # The error as Smith takes it: a billing refusal as a
35
+ # RubyLLM::PaymentRequiredError, anything else unchanged.
36
+ def classified(error)
37
+ return error unless refusal?(error)
38
+
39
+ begin
40
+ raise RubyLLM::PaymentRequiredError.new(error.response, error.message), cause: error
41
+ rescue RubyLLM::PaymentRequiredError => e
42
+ e
43
+ end
44
+ end
45
+
46
+ def refusal?(error)
47
+ return false unless CLASSES.any? { error.is_a?(_1) }
48
+
49
+ said = provider_error(error)
50
+ details = said["details"].is_a?(Hash) ? said["details"] : {}
51
+
52
+ TYPES.include?(said["type"]) ||
53
+ CODES.include?(details["error_code"]) ||
54
+ MESSAGES.any? { error.message.to_s.match?(_1) }
55
+ end
56
+
57
+ # The provider's own error object in the response body, or {} when the
58
+ # body carries none.
59
+ def provider_error(error)
60
+ body = error.response.respond_to?(:body) ? error.response.body : nil
61
+ body = JSON.parse(body) if body.is_a?(String)
62
+ said = body["error"] if body.is_a?(Hash)
63
+ said.is_a?(Hash) ? said : {}
64
+ rescue JSON::ParserError
65
+ {}
66
+ end
67
+ end
68
+ end
@@ -34,13 +34,13 @@ module Smith
34
34
  @connection = RubyLLM::Providers::Anthropic.new(config).connection
35
35
  end
36
36
 
37
- # Submits the requests; returns the batch id.
37
+ # Submits the requests; returns the batch id. A refusal for want of
38
+ # credit raises RubyLLM::PaymentRequiredError (BillingRefusals).
38
39
  def submit(requests)
39
40
  entries = Array(requests).map { |entry| entry.transform_keys(&:to_sym) }
40
41
  body = { requests: entries.map { |entry| { custom_id: entry.fetch(:custom_id), params: params(entry) } } }
41
42
  check_batch!(entries, body)
42
- headers = shared_headers(entries)
43
- @connection.post(PATH, body) { |request| add_headers(request.headers, headers) }.body.fetch("id")
43
+ create(body, shared_headers(entries))
44
44
  end
45
45
 
46
46
  # The batch's processing status ("in_progress", "canceling" or
@@ -67,6 +67,12 @@ module Smith
67
67
 
68
68
  private
69
69
 
70
+ def create(body, headers)
71
+ @connection.post(PATH, body) { |request| add_headers(request.headers, headers) }.body.fetch("id")
72
+ rescue RubyLLM::Error => e
73
+ raise BillingRefusals.classified(e)
74
+ end
75
+
70
76
  def params(entry)
71
77
  request = entry.fetch(:request).transform_keys(&:to_s)
72
78
  raise ArgumentError, "a request for #{request["provider"]} cannot go in an Anthropic batch" unless
data/lib/smith/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.12.3"
4
+ VERSION = "0.12.5"
5
5
  EXECUTION_SEMANTICS_VERSION = "7"
6
6
  end
data/lib/smith.rb CHANGED
@@ -212,6 +212,7 @@ require_relative "smith/tool_failure_notification_failed"
212
212
  require_relative "smith/persistence_payload_conflict"
213
213
  require_relative "smith/exponential_backoff"
214
214
  require_relative "smith/interruptions"
215
+ require_relative "smith/billing_refusals"
215
216
 
216
217
  # Event system (depends on Types)
217
218
  require_relative "smith/event"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.3
4
+ version: 0.12.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak
@@ -161,6 +161,7 @@ files:
161
161
  - lib/generators/smith/install/templates/workflow_state.rb.tt
162
162
  - lib/smith.rb
163
163
  - lib/smith/agent.rb
164
+ - lib/smith/agent/attempt_chat.rb
164
165
  - lib/smith/agent/chat_construction.rb
165
166
  - lib/smith/agent/completion.rb
166
167
  - lib/smith/agent/completion_usage_recording.rb
@@ -203,6 +204,7 @@ files:
203
204
  - lib/smith/attribution.rb
204
205
  - lib/smith/batch.rb
205
206
  - lib/smith/batch/message_shape.rb
207
+ - lib/smith/billing_refusals.rb
206
208
  - lib/smith/budget.rb
207
209
  - lib/smith/budget/amount.rb
208
210
  - lib/smith/budget/amount_contract.rb