smith-agents 0.12.2 → 0.12.4
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/CHANGELOG.md +50 -0
- data/README.md +1 -1
- data/docs/AGENTS.md +4 -1
- data/docs/ERRORS_AND_RETRIES.md +23 -4
- data/lib/smith/agent/provider_call_timing.rb +2 -0
- data/lib/smith/agent/provider_completion.rb +19 -5
- data/lib/smith/batch.rb +11 -9
- data/lib/smith/billing_refusals.rb +68 -0
- data/lib/smith/cache_markers.rb +86 -0
- data/lib/smith/providers/anthropic/batches.rb +9 -3
- data/lib/smith/response_memo.rb +2 -2
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/persistence.rb +4 -1
- data/lib/smith/workflow.rb +4 -2
- data/lib/smith.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2c791c5165889ff1098cc5c92976b1affe2c3f82108815fda1c98fae56c0430
|
|
4
|
+
data.tar.gz: 5b7d1d9f41e2949c335d6dc8b26a7f69aff73c2edf4ee39e100760ca3c4f4145
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e76354065df47cf04703bc41cda18256df9f2118a9d714f046f3fe04c567256d969be828235b0a6da2ff0ee83fe8953ba8c381ed4209c4f49776cde1658ab5d0
|
|
7
|
+
data.tar.gz: 3e73cddd45cd37191712cc2ed4d5cdc1fe62dbe07a2de73b86bd626f43eda0ba74efcbf8b40fabbbc15db21e6517495567ac346c4339f3c9864e316894170d3f
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@ 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.4] - 2026-10-13
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- A provider's refusal for want of credit or past a spending limit is
|
|
12
|
+
`RubyLLM::PaymentRequiredError` whatever status it came with
|
|
13
|
+
(`Smith::BillingRefusals`), with the error RubyLLM raised as its cause. A
|
|
14
|
+
refusal is told by what the provider says it is: Anthropic's `billing_error`,
|
|
15
|
+
its tier's monthly spend cap (a 429 whose `error_code` is
|
|
16
|
+
`enforced_spend_limit_reached`), its 400s for spent credit ("Your credit
|
|
17
|
+
balance is too low") and for a spend limit the account set ("You have
|
|
18
|
+
reached your specified API usage limits"), and OpenAI's
|
|
19
|
+
`insufficient_quota`. A streamed request's refusal, which RubyLLM raises as
|
|
20
|
+
a `RubyLLM::ServerError`, is one too, and so is one met by
|
|
21
|
+
`Smith::Providers::Anthropic::Batches#submit`. It is the provider account's
|
|
22
|
+
failure, so another provider in the fallback chain is tried, and a host can
|
|
23
|
+
name `RubyLLM::PaymentRequiredError` in `interrupting_errors` to have a step
|
|
24
|
+
wait for credit instead of failing. Any other error is unchanged: a rate
|
|
25
|
+
limit that mentions a quota (Gemini's) stays a rate limit.
|
|
26
|
+
- A model candidate that met an interrupting error outlasts how a later
|
|
27
|
+
fallback fails: the step is cut short, as it would have been with no
|
|
28
|
+
fallback, instead of failing on the fallback's error.
|
|
29
|
+
|
|
30
|
+
## [0.12.3] - 2026-10-12
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- What identifies a request leaves prompt-cache markers out
|
|
35
|
+
(`Smith::CacheMarkers`): the response memo's key, a batch request's digest
|
|
36
|
+
and a seed's drift digest (`seed_validation`). A marker says how a request
|
|
37
|
+
is billed, never what it asks, so a host may decide call by call whether to
|
|
38
|
+
cache a part (an agent's instructions, a content part, the whole request)
|
|
39
|
+
and a replay still finds what the first call got, as long as the part keeps
|
|
40
|
+
its form (the same text block, with or without its marker). Only the places
|
|
41
|
+
Anthropic puts `cache_control` are looked at (the request, its system and
|
|
42
|
+
message blocks, a tool result's blocks, its tools) and Smith's own
|
|
43
|
+
`"cache" => true` on a seed's content parts; a tool's input or schema, or
|
|
44
|
+
anything else a request carries as data, is never touched. A batch result
|
|
45
|
+
for a request that differs from the one sent only in its markers is no
|
|
46
|
+
longer taken as a changed request.
|
|
47
|
+
|
|
48
|
+
### Upgrading
|
|
49
|
+
|
|
50
|
+
- Response memo records kept by 0.12.2 for calls that carried a cache marker
|
|
51
|
+
are not found after the upgrade (their key took the marker in), so such a
|
|
52
|
+
call cut by the upgrade's deploy is paid for once more. A batch request
|
|
53
|
+
captured by 0.12.2, and a run's seed digest taken by 0.12.2, still match as
|
|
54
|
+
they were taken, cache markers included: one whose markers change after the
|
|
55
|
+
upgrade counts as changed, as before.
|
|
56
|
+
|
|
7
57
|
## [0.12.2] - 2026-10-12
|
|
8
58
|
|
|
9
59
|
### Added
|
data/README.md
CHANGED
data/docs/AGENTS.md
CHANGED
|
@@ -216,7 +216,10 @@ without sleeping: `Smith::Providers::Anthropic::Batches` gives the host
|
|
|
216
216
|
`submit`, `status`, `results` and `cancel`. `resume!` with
|
|
217
217
|
`{custom_id => result}` runs the step again, each call taking its result as
|
|
218
218
|
the reply it would have got. A call that renders another request by then (a
|
|
219
|
-
date in its prompt, data read afresh, a prompt changed in a deploy
|
|
219
|
+
date in its prompt, data read afresh, a prompt changed in a deploy; a prompt
|
|
220
|
+
cache marker added or dropped is no change, as it never changes what the
|
|
221
|
+
request asks, though a request captured by Smith 0.12.2 is matched as it was
|
|
222
|
+
sent, markers included) does what
|
|
220
223
|
its step's `on_request_change:` says: `:keep` (the default) takes the result,
|
|
221
224
|
which answers the request as sent, and its `:provider_call` trace says
|
|
222
225
|
`request_changed: true`; `:resend` sends the request as rendered now over the
|
data/docs/ERRORS_AND_RETRIES.md
CHANGED
|
@@ -99,6 +99,23 @@ 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 or a
|
|
117
|
+
streamed refusal before Smith sees it; a refused request is not charged.
|
|
118
|
+
|
|
102
119
|
Name only errors that mean your own systems failed. A provider's own failure
|
|
103
120
|
(`RubyLLM::Error`, or its HTTP client's `Faraday::Error`) is the provider's
|
|
104
121
|
whatever it was raised from, so naming a low-level class such as
|
|
@@ -138,10 +155,12 @@ attempts it runs under (its step's, and each enclosing run's for a nested
|
|
|
138
155
|
workflow, so a retry never takes the attempt before's answer), its attempt
|
|
139
156
|
index (its model in the fallback chain, or a follow-up's place), its place
|
|
140
157
|
among the attempt's calls, and a digest of its request (model, messages,
|
|
141
|
-
tools with their descriptions and parameters, schema, params)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
158
|
+
tools with their descriptions and parameters, schema, params), Anthropic's
|
|
159
|
+
prompt-cache markers aside: whether a part is cached never changes what the
|
|
160
|
+
call asks, so a host may decide it call by call, keeping the part's form (the
|
|
161
|
+
same text block, with or without its marker). A replay reaches the same key; a
|
|
162
|
+
call whose request changed since (a deployed prompt or tool, another model, a
|
|
163
|
+
date in the prompt) misses and is sent again, as a miss always may be.
|
|
145
164
|
|
|
146
165
|
Its record (`{"version" => 1 | 2, "messages" => [...], "complete" => true |
|
|
147
166
|
false}`, JSON) holds the messages the call added: the model's replies
|
|
@@ -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
|
-
|
|
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
|
data/lib/smith/batch.rb
CHANGED
|
@@ -45,16 +45,18 @@ module Smith
|
|
|
45
45
|
deep_freeze(JSON.parse(encoded))
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# What identifies a request's content: a SHA-256 of its payload
|
|
49
|
-
# headers as JSON gives them, so
|
|
50
|
-
# told from one the step would
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
#
|
|
55
|
-
#
|
|
48
|
+
# What identifies a request's content: a SHA-256 of its payload, prompt
|
|
49
|
+
# cache markers aside (CacheMarkers), and headers as JSON gives them, so
|
|
50
|
+
# the request a delivery answers can be told from one the step would
|
|
51
|
+
# render now.
|
|
52
|
+
def digest(request) = digest_of(CacheMarkers.strip_request(request.fetch("params")), request.fetch("headers"))
|
|
53
|
+
|
|
54
|
+
# Whether a call renders a request other than the one sent (`digest`),
|
|
55
|
+
# cache markers aside; a digest Smith 0.12.2 took with its markers still
|
|
56
|
+
# matches the request as it was. Nothing is sent, so nothing in it is
|
|
57
|
+
# refused; one JSON cannot give counts as changed.
|
|
56
58
|
def changed?(digest, params, headers)
|
|
57
|
-
|
|
59
|
+
[CacheMarkers.strip_request(params), params].none? { digest_of(_1, headers.to_h) == digest }
|
|
58
60
|
rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
|
|
59
61
|
true
|
|
60
62
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
# Anthropic's prompt-cache markers (`cache_control: { type: "ephemeral" }`
|
|
5
|
+
# on a request, a system block, a content block, a tool result's block or
|
|
6
|
+
# a tool) and Smith's own `"cache" => true` on a content part say how a
|
|
7
|
+
# request is billed, never what it asks: the reply is the same with or
|
|
8
|
+
# without them. What identifies a request (the response memo's key, a batch
|
|
9
|
+
# request's digest, a seed's drift digest) leaves them out, so a host that
|
|
10
|
+
# caches a prompt on one call and not on the next still finds what the
|
|
11
|
+
# first call got. Only the places markers can sit are looked at: a tool's
|
|
12
|
+
# input or schema, or anything else a request carries as data, is never
|
|
13
|
+
# touched, and nothing is walked deeper than a tool result's blocks.
|
|
14
|
+
module CacheMarkers
|
|
15
|
+
MARKER_KEYS = [:cache_control, "cache_control"].freeze
|
|
16
|
+
private_constant :MARKER_KEYS
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# A request payload (a batch request's params, a chat's params): its own
|
|
21
|
+
# marker, and those of its system blocks, its messages' blocks and its
|
|
22
|
+
# tools.
|
|
23
|
+
def strip_request(request)
|
|
24
|
+
return request unless request.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
request = unmarked(request)
|
|
27
|
+
request = replaced(request, :system) { strip_blocks(_1) }
|
|
28
|
+
request = replaced(request, :messages) do |messages|
|
|
29
|
+
messages.is_a?(Array) ? messages.map { |message| replaced(message, :content) { strip_blocks(_1) } } : messages
|
|
30
|
+
end
|
|
31
|
+
replaced(request, :tools) { |tools| tools.is_a?(Array) ? tools.map { unmarked(_1) } : tools }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A message's content: a block or blocks, a tool result's own blocks
|
|
35
|
+
# included.
|
|
36
|
+
def strip_blocks(content)
|
|
37
|
+
case content
|
|
38
|
+
when Array then content.map { strip_block(_1) }
|
|
39
|
+
when Hash then strip_block(content)
|
|
40
|
+
else content
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Seed messages as Smith keeps them: each content part without its
|
|
45
|
+
# `"cache" => true`.
|
|
46
|
+
def strip_parts(messages)
|
|
47
|
+
return messages unless messages.is_a?(Array)
|
|
48
|
+
|
|
49
|
+
messages.map do |message|
|
|
50
|
+
replaced(message, :content) { |content| content.is_a?(Array) ? content.map { unflagged(_1) } : content }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def unflagged(part) = part.is_a?(Hash) ? part.except("cache", :cache) : part
|
|
55
|
+
|
|
56
|
+
def strip_block(block)
|
|
57
|
+
return block unless block.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
block = unmarked(block)
|
|
60
|
+
return block unless (block[:type] || block["type"]).to_s == "tool_result"
|
|
61
|
+
|
|
62
|
+
replaced(block, :content) { |content| content.is_a?(Array) ? content.map { unmarked(_1) } : unmarked(content) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def unmarked(value)
|
|
66
|
+
return value unless value.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
value.reject { |key, item| MARKER_KEYS.include?(key) && marker?(item) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def marker?(value) = value.is_a?(Hash) && (value[:type] || value["type"]).to_s == "ephemeral"
|
|
72
|
+
|
|
73
|
+
# The Hash with its value under `key` (a Symbol or a String key) passed
|
|
74
|
+
# through the block.
|
|
75
|
+
def replaced(hash, key)
|
|
76
|
+
return hash unless hash.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
[key, key.to_s].each do |name|
|
|
79
|
+
hash = hash.merge(name => yield(hash[name])) if hash.key?(name)
|
|
80
|
+
end
|
|
81
|
+
hash
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private_class_method :strip_block, :unflagged, :unmarked, :marker?, :replaced
|
|
85
|
+
end
|
|
86
|
+
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
|
-
|
|
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/response_memo.rb
CHANGED
|
@@ -149,7 +149,7 @@ module Smith
|
|
|
149
149
|
|
|
150
150
|
def request_digest(chat)
|
|
151
151
|
shape = [chat.model.id, chat.model.provider.to_s, chat.messages.map { message_shape(_1) },
|
|
152
|
-
tools_shape(chat.tools), chat.schema, chat.params]
|
|
152
|
+
tools_shape(chat.tools), chat.schema, CacheMarkers.strip_request(chat.params)]
|
|
153
153
|
Digest::SHA256.hexdigest(JSON.generate(shape))
|
|
154
154
|
rescue StandardError
|
|
155
155
|
nil
|
|
@@ -158,7 +158,7 @@ module Smith
|
|
|
158
158
|
def message_shape(message)
|
|
159
159
|
content = message.content
|
|
160
160
|
body = case content
|
|
161
|
-
when RubyLLM::Content::Raw then content.value
|
|
161
|
+
when RubyLLM::Content::Raw then CacheMarkers.strip_blocks(content.value)
|
|
162
162
|
when RubyLLM::Content
|
|
163
163
|
[content.text, content.attachments.map { [_1.filename, _1.mime_type, _1.content.to_s.bytesize] }]
|
|
164
164
|
else content
|
data/lib/smith/version.rb
CHANGED
|
@@ -228,13 +228,16 @@ module Smith
|
|
|
228
228
|
validate_step_in_progress!(fields.to_h { |field| [field, state[field] || state[field.to_s]] })
|
|
229
229
|
end
|
|
230
230
|
|
|
231
|
+
# A digest Smith before 0.12.3 took, its parts' cache flags included.
|
|
232
|
+
def flagged_seed_digest?(messages, stored) = compute_seed_digest(messages, cache_flags: true) == stored
|
|
233
|
+
|
|
231
234
|
def validate_seed_digest!(normalized)
|
|
232
235
|
stored_digest = normalized[:seed_digest]
|
|
233
236
|
return if stored_digest.nil?
|
|
234
237
|
|
|
235
238
|
current_messages = compute_seed_messages
|
|
236
239
|
current_digest = compute_seed_digest(current_messages)
|
|
237
|
-
return if current_digest == stored_digest
|
|
240
|
+
return if current_digest == stored_digest || flagged_seed_digest?(current_messages, stored_digest)
|
|
238
241
|
|
|
239
242
|
case self.class.seed_validation
|
|
240
243
|
when :strict
|
data/lib/smith/workflow.rb
CHANGED
|
@@ -412,10 +412,12 @@ module Smith
|
|
|
412
412
|
normalize_seed_messages(seeded)
|
|
413
413
|
end
|
|
414
414
|
|
|
415
|
-
|
|
415
|
+
# A seed's drift digest, its parts' cache flags aside (CacheMarkers);
|
|
416
|
+
# with them, as Smith before 0.12.3 took it.
|
|
417
|
+
def compute_seed_digest(messages, cache_flags: false)
|
|
416
418
|
return nil if messages.nil? || messages.empty?
|
|
417
419
|
|
|
418
|
-
Digest::SHA256.hexdigest(JSON.generate(messages))
|
|
420
|
+
Digest::SHA256.hexdigest(JSON.generate(cache_flags ? messages : CacheMarkers.strip_parts(messages)))
|
|
419
421
|
rescue JSON::GeneratorError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e
|
|
420
422
|
raise WorkflowError,
|
|
421
423
|
"seed_messages content must be valid UTF-8 (Smith hashes via JSON.generate " \
|
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"
|
|
@@ -227,6 +228,7 @@ require_relative "smith/budget/ledger"
|
|
|
227
228
|
require_relative "smith/budget/host_ledger"
|
|
228
229
|
require_relative "smith/budget/request_hold"
|
|
229
230
|
require_relative "smith/budget/continuation"
|
|
231
|
+
require_relative "smith/cache_markers"
|
|
230
232
|
require_relative "smith/response_memo"
|
|
231
233
|
|
|
232
234
|
# Pricing (depends on Smith config)
|
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.
|
|
4
|
+
version: 0.12.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Samuel Ralak
|
|
@@ -203,6 +203,7 @@ files:
|
|
|
203
203
|
- lib/smith/attribution.rb
|
|
204
204
|
- lib/smith/batch.rb
|
|
205
205
|
- lib/smith/batch/message_shape.rb
|
|
206
|
+
- lib/smith/billing_refusals.rb
|
|
206
207
|
- lib/smith/budget.rb
|
|
207
208
|
- lib/smith/budget/amount.rb
|
|
208
209
|
- lib/smith/budget/amount_contract.rb
|
|
@@ -219,6 +220,7 @@ files:
|
|
|
219
220
|
- lib/smith/budget/reservation.rb
|
|
220
221
|
- lib/smith/budget/reservation_contract.rb
|
|
221
222
|
- lib/smith/budget/spent_reservation.rb
|
|
223
|
+
- lib/smith/cache_markers.rb
|
|
222
224
|
- lib/smith/child_run.rb
|
|
223
225
|
- lib/smith/child_runs_failed.rb
|
|
224
226
|
- lib/smith/cli.rb
|