smith-agents 0.10.0 → 0.12.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/CHANGELOG.md +901 -0
- data/README.md +636 -20
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +100 -14
- data/docs/PATTERNS.md +88 -12
- data/docs/PERSISTENCE.md +738 -6
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +63 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +17 -4
- data/lib/smith/agent/provider_call_timing.rb +71 -7
- data/lib/smith/agent/provider_completion.rb +41 -38
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +75 -2
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -48
- data/lib/smith/agent.rb +14 -1
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +10 -4
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +21 -6
- data/lib/smith/context.rb +28 -0
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +35 -17
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +304 -6
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +14 -8
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +62 -11
- data/lib/smith/workflow/deterministic_step.rb +56 -7
- data/lib/smith/workflow/dsl.rb +74 -5
- data/lib/smith/workflow/durability.rb +140 -31
- data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +32 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +41 -2
- data/lib/smith/workflow/failure_record.rb +12 -12
- data/lib/smith/workflow/failure_record_validator.rb +32 -5
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/guardrail_integration.rb +2 -0
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +162 -22
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +71 -8
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +40 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +117 -19
- data/lib/smith.rb +103 -47
- metadata +88 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../diagnostic_text"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
class Agent
|
|
5
7
|
# Monotonic timing and trace emission for one provider attempt: the
|
|
@@ -7,6 +9,9 @@ module Smith
|
|
|
7
9
|
# (including any provider-side tool loop). Per-network-round timing
|
|
8
10
|
# belongs to the host's RubyLLM notification subscriptions, not Smith.
|
|
9
11
|
module ProviderCallTiming
|
|
12
|
+
EXCEPTION_CAUSE = Exception.instance_method(:cause)
|
|
13
|
+
private_constant :EXCEPTION_CAUSE
|
|
14
|
+
|
|
10
15
|
class Timer
|
|
11
16
|
def self.start
|
|
12
17
|
new
|
|
@@ -38,7 +43,11 @@ module Smith
|
|
|
38
43
|
# durations across entries. duration_ms is present only when the timed
|
|
39
44
|
# provider call actually started: a failure before dispatch (model
|
|
40
45
|
# resolution, chat construction) emits its attempt without a duration.
|
|
41
|
-
|
|
46
|
+
# input_tokens and output_tokens total the attempt's usage entries, so
|
|
47
|
+
# a host can record the whole attempt when it ends. request_changed
|
|
48
|
+
# marks a batch result that answered the request sent, not the one the
|
|
49
|
+
# call renders now.
|
|
50
|
+
def record_provider_call_trace(attempt, aborted: false)
|
|
42
51
|
Smith::Trace.record(
|
|
43
52
|
type: :provider_call,
|
|
44
53
|
data: {
|
|
@@ -46,12 +55,30 @@ module Smith
|
|
|
46
55
|
provider: attempt.model_reference.provider,
|
|
47
56
|
duration_ms: attempt.duration_ms,
|
|
48
57
|
attempt_id: attempt.attempt_id,
|
|
49
|
-
attempt_index: attempt_index,
|
|
50
|
-
outcome: provider_call_outcome(attempt, aborted)
|
|
58
|
+
attempt_index: attempt.attempt_index,
|
|
59
|
+
outcome: provider_call_outcome(attempt, aborted),
|
|
60
|
+
**provider_call_error_classes(attempt.error),
|
|
61
|
+
agent_name: attempt.agent_name,
|
|
62
|
+
request_changed: attempt.request_changed,
|
|
63
|
+
memo: attempt.memo,
|
|
64
|
+
**provider_call_usage(attempt.usage)
|
|
51
65
|
}.compact
|
|
52
66
|
)
|
|
53
67
|
end
|
|
54
68
|
|
|
69
|
+
# Token counts are metadata, never content, so the content policy
|
|
70
|
+
# leaves them in place.
|
|
71
|
+
def provider_call_usage(usage)
|
|
72
|
+
return {} unless usage
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
|
|
76
|
+
cache_read_tokens: usage.cache_read_tokens, cache_write_tokens: usage.cache_write_tokens,
|
|
77
|
+
thinking_tokens: usage.thinking_tokens, stop_reason: usage.stop_reason,
|
|
78
|
+
provider_message_id: usage.provider_message_id, mode: usage.mode
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
55
82
|
def provider_call_outcome(attempt, aborted)
|
|
56
83
|
return :aborted if aborted
|
|
57
84
|
return :success if attempt.success?
|
|
@@ -59,14 +86,51 @@ module Smith
|
|
|
59
86
|
:failure
|
|
60
87
|
end
|
|
61
88
|
|
|
89
|
+
# Class names only, never a message: the same bounded identifier the
|
|
90
|
+
# failed :transition trace carries, for the error and its direct cause.
|
|
91
|
+
def provider_call_error_classes(error)
|
|
92
|
+
return {} unless error
|
|
93
|
+
|
|
94
|
+
cause = provider_call_error_cause(error)
|
|
95
|
+
{
|
|
96
|
+
error_class: DiagnosticText.error_class_name(error),
|
|
97
|
+
error_cause_class: cause && DiagnosticText.error_class_name(cause)
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A transport error wraps what actually happened (never connected, or
|
|
102
|
+
# lost the connection after sending); Faraday keeps it as
|
|
103
|
+
# wrapped_exception when the error was not raised inside a rescue.
|
|
104
|
+
def provider_call_error_cause(error)
|
|
105
|
+
cause = EXCEPTION_CAUSE.bind_call(error)
|
|
106
|
+
cause ||= error.wrapped_exception if error.respond_to?(:wrapped_exception)
|
|
107
|
+
cause if cause.is_a?(Exception)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def completed_provider_attempt(completion, observed_reference, timer, facts)
|
|
113
|
+
usage = ProviderUsage.sum(completion.provider_usages)&.with_response_facts(completion.response_facts)
|
|
114
|
+
attempt = ProviderAttempt.success(
|
|
115
|
+
completion:, model_reference: observed_reference, duration_ms: timer.elapsed_ms, usage:, **facts
|
|
116
|
+
)
|
|
117
|
+
record_provider_call_trace(attempt)
|
|
118
|
+
attempt
|
|
119
|
+
end
|
|
120
|
+
|
|
62
121
|
# Aborted (non-provider) attempts emit a :provider_call too, so the
|
|
63
122
|
# prefix-accounted usage entries stamped with this attempt_id always
|
|
64
|
-
# have their join target; the error then propagates unchanged.
|
|
65
|
-
|
|
123
|
+
# have their join target; the error then propagates unchanged. A
|
|
124
|
+
# provider failure's own reported usage is accounted beside the
|
|
125
|
+
# completed prefix, so its trace totals both.
|
|
126
|
+
def failed_provider_attempt(error, observed_reference, timer, facts, prefix_usages)
|
|
127
|
+
aborted = !provider_failure?(error)
|
|
128
|
+
usages = aborted ? prefix_usages : [*prefix_usages, ProviderUsage.from_message(error)].compact
|
|
66
129
|
attempt = ProviderAttempt.failure(
|
|
67
|
-
error
|
|
130
|
+
error:, model_reference: observed_reference, duration_ms: timer&.elapsed_ms,
|
|
131
|
+
usage: ProviderUsage.sum(usages), **facts
|
|
68
132
|
)
|
|
69
|
-
record_provider_call_trace(attempt,
|
|
133
|
+
record_provider_call_trace(attempt, aborted:)
|
|
70
134
|
attempt
|
|
71
135
|
end
|
|
72
136
|
end
|
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "provider_failure_handling"
|
|
4
|
+
require_relative "content_bytes"
|
|
5
|
+
require_relative "content_parts"
|
|
4
6
|
require_relative "invocation_preparation"
|
|
5
7
|
require_relative "provider_attempt"
|
|
6
8
|
require_relative "provider_call_timing"
|
|
9
|
+
require_relative "provider_request"
|
|
7
10
|
require_relative "provider_candidate_sequence"
|
|
11
|
+
require_relative "provider_streaming"
|
|
12
|
+
require_relative "observed_model"
|
|
8
13
|
|
|
9
14
|
module Smith
|
|
10
15
|
class Agent
|
|
11
16
|
module ProviderCompletion
|
|
12
17
|
include ProviderFailureHandling
|
|
13
18
|
include InvocationPreparation
|
|
19
|
+
include ContentParts
|
|
14
20
|
include ProviderCallTiming
|
|
21
|
+
include ProviderRequest
|
|
22
|
+
include ProviderStreaming
|
|
23
|
+
include ObservedModel
|
|
15
24
|
|
|
16
25
|
private
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
# The input's documents and images are read once (ContentBytes), and
|
|
28
|
+
# its cached parts checked against every Anthropic model in the chain,
|
|
29
|
+
# before the first attempt.
|
|
30
|
+
def complete_with_provider(agent_class, prepared_input, shape:)
|
|
31
|
+
chain = build_model_chain(agent_class).tap { check_chain_cache_breakpoints!(_1, prepared_input) }
|
|
32
|
+
ContentBytes.with(prepared_input) { attempt_candidates(agent_class, prepared_input, chain, shape) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def attempt_candidates(agent_class, prepared_input, chain, shape)
|
|
36
|
+
candidates = ProviderCandidateSequence.new(chain)
|
|
20
37
|
candidates.each do |model_reference, index|
|
|
21
38
|
check_deadline! if index.positive?
|
|
22
|
-
|
|
39
|
+
withdraw_streamed(index.positive? ? :fallback : :retry)
|
|
40
|
+
attempt = attempt_model(agent_class, prepared_input, model_reference, shape:, attempt_index: index)
|
|
23
41
|
return attempt if attempt.success?
|
|
24
42
|
|
|
25
43
|
candidates.suppress(account_failed_provider(attempt, model_reference))
|
|
@@ -33,7 +51,7 @@ module Smith
|
|
|
33
51
|
# Fail closed: an empty candidate chain (agent without a model)
|
|
34
52
|
# or any exhausted sequence must surface a typed diagnostic, not
|
|
35
53
|
# the sequence itself destructured into nils.
|
|
36
|
-
raise Smith::
|
|
54
|
+
raise Smith::WorkflowError,
|
|
37
55
|
"no executable model candidate for #{agent_class}; declare a model or fallback_models"
|
|
38
56
|
end
|
|
39
57
|
|
|
@@ -68,6 +86,8 @@ module Smith
|
|
|
68
86
|
end
|
|
69
87
|
|
|
70
88
|
def fallback_model_references(agent_class)
|
|
89
|
+
return agent_class.resolve_fallback_models(@context || {}) if agent_class.fallback_models_block
|
|
90
|
+
|
|
71
91
|
Array(agent_class.fallback_models).map { |model| ModelReference.coerce(model) }
|
|
72
92
|
end
|
|
73
93
|
|
|
@@ -86,54 +106,37 @@ module Smith
|
|
|
86
106
|
# cohesive lifecycle (identity, prepared chat, observed model, timed
|
|
87
107
|
# completion, prefix accounting on failure); splitting it would scatter
|
|
88
108
|
# the rescue-path accounting away from what it accounts for.
|
|
89
|
-
def attempt_model(agent_class, prepared_input, model_reference,
|
|
90
|
-
|
|
91
|
-
chat = prepared_attempt_chat(agent_class, prepared_input, model_reference,
|
|
109
|
+
def attempt_model(agent_class, prepared_input, model_reference, shape:, attempt_index:)
|
|
110
|
+
facts = { attempt_id: SecureRandom.uuid, attempt_index:, agent_name: agent_class.register_as }
|
|
111
|
+
chat = prepared_attempt_chat(agent_class, prepared_input, model_reference, shape)
|
|
92
112
|
message_count = chat_message_count(chat)
|
|
93
113
|
observed_reference = observed_model_reference(chat, fallback: model_reference)
|
|
94
114
|
timer = ProviderCallTiming::Timer.start
|
|
95
|
-
response = chat
|
|
115
|
+
response, stream, mode, call_facts = complete_chat(chat, observed_reference, collected = [], facts)
|
|
96
116
|
timer.stop
|
|
97
|
-
completion = Completion.from_messages(response
|
|
117
|
+
completion = Completion.from_messages(response:, messages: new_chat_messages(chat, message_count), stream:,
|
|
118
|
+
mode:)
|
|
98
119
|
|
|
99
|
-
|
|
100
|
-
completion:, model_reference: observed_reference, attempt_id:, duration_ms: timer.elapsed_ms
|
|
101
|
-
)
|
|
102
|
-
record_provider_call_trace(attempt, attempt_index)
|
|
103
|
-
attempt
|
|
120
|
+
completed_provider_attempt(completion, observed_reference, timer, facts.merge(call_facts.to_h))
|
|
104
121
|
rescue StandardError => e
|
|
105
122
|
timer&.stop
|
|
106
123
|
observed_reference ||= observed_model_reference(chat, fallback: model_reference)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
prefix_usages = account_completed_prefix(
|
|
125
|
+
agent_class, observed_reference, new_chat_messages(chat, message_count),
|
|
126
|
+
attempt_id: facts[:attempt_id], stream_rounds: streamed_rounds(collected || [])
|
|
127
|
+
)
|
|
128
|
+
attempt = failed_provider_attempt(e, observed_reference, timer, facts, prefix_usages)
|
|
129
|
+
raise unless provider_failure?(e) || unsupported_content?(e)
|
|
110
130
|
|
|
111
131
|
attempt
|
|
112
132
|
end
|
|
113
133
|
# rubocop:enable Metrics/AbcSize
|
|
114
134
|
|
|
115
|
-
def
|
|
116
|
-
model = observable_model(chat)
|
|
117
|
-
return fallback unless model.respond_to?(:id) && !model.id.to_s.empty?
|
|
118
|
-
|
|
119
|
-
ModelReference.coerce(model.id, provider: observed_provider(model, fallback))
|
|
120
|
-
rescue StandardError
|
|
121
|
-
fallback
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def observable_model(chat)
|
|
125
|
-
resolved_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
|
|
126
|
-
resolved_chat.model if resolved_chat.respond_to?(:model)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def observed_provider(model, fallback)
|
|
130
|
-
model.respond_to?(:provider) && !model.provider.to_s.empty? ? model.provider : fallback.provider
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def prepared_attempt_chat(agent_class, prepared_input, model_reference, output_schema:)
|
|
135
|
+
def prepared_attempt_chat(agent_class, prepared_input, model_reference, shape)
|
|
134
136
|
chat = agent_class.chat(**model_reference.chat_options, **bridge_workflow_inputs(agent_class))
|
|
135
|
-
add_prepared_input(chat, prepared_input)
|
|
136
|
-
|
|
137
|
+
add_prepared_input(chat, prepared_input, observed_model_reference(chat, fallback: model_reference).provider)
|
|
138
|
+
continue_paused_loop(chat)
|
|
139
|
+
shaped_chat(chat, model_reference, shape)
|
|
137
140
|
end
|
|
138
141
|
|
|
139
142
|
def chat_message_count(chat)
|
|
@@ -49,7 +49,8 @@ module Smith
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def fallback_eligible?(error)
|
|
52
|
-
|
|
52
|
+
unsupported_content?(error) ||
|
|
53
|
+
transient_failure?(error) ||
|
|
53
54
|
transport_failure?(error) ||
|
|
54
55
|
provider_account_failure?(error) ||
|
|
55
56
|
model_permission_failure?(error) ||
|
|
@@ -80,6 +81,9 @@ module Smith
|
|
|
80
81
|
|
|
81
82
|
def terminal_provider_error(error, model_reference)
|
|
82
83
|
return Smith::AgentError.new(error.message) if transient_failure?(error) || transport_failure?(error)
|
|
84
|
+
if unsupported_content?(error)
|
|
85
|
+
return Smith::ContentUnavailable.new("#{model_reference} takes none of this content: #{error.message}")
|
|
86
|
+
end
|
|
83
87
|
|
|
84
88
|
Smith::ProviderPermanentFailure.new(
|
|
85
89
|
error.message,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# What one provider call asks for beyond its messages: the output schema
|
|
6
|
+
# as the provider takes it, and a larger output limit when a cut-off
|
|
7
|
+
# response is retried.
|
|
8
|
+
module ProviderRequest
|
|
9
|
+
Shape = Data.define(:output_schema, :output_limit)
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def shaped_chat(chat, model_reference, shape)
|
|
14
|
+
provider = observed_model_reference(chat, fallback: model_reference).provider
|
|
15
|
+
chat = with_output_limit(chat, provider, shape.output_limit) if shape.output_limit
|
|
16
|
+
shape.output_schema ? chat.with_schema(provider_output_schema(shape.output_schema, provider)) : chat
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def with_output_limit(chat, provider, limit)
|
|
20
|
+
return chat unless chat.respond_to?(:with_params)
|
|
21
|
+
|
|
22
|
+
params = (chat.params if chat.respond_to?(:params)) || {}
|
|
23
|
+
chat.with_params(**::RubyLLM::Utils.deep_merge(params, Providers::OutputLimit.params(provider, params, limit)))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# For Anthropic without the constraints it rejects (SchemaConstraints);
|
|
27
|
+
# unchanged otherwise.
|
|
28
|
+
def provider_output_schema(output_schema, provider)
|
|
29
|
+
return output_schema unless provider.to_s == "anthropic"
|
|
30
|
+
|
|
31
|
+
payload = Providers::SchemaConstraints.json_schema(output_schema)
|
|
32
|
+
payload ? Providers::SchemaConstraints.for_provider(payload, provider) : output_schema
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# The provider calls of a streamed step (Smith::Streaming): before a
|
|
6
|
+
# call that replaces an earlier one, the earlier call's text is
|
|
7
|
+
# withdrawn; each call is announced and its text chunks sent. The stop
|
|
8
|
+
# reason is read from the stream (Providers::StreamFacts), as a
|
|
9
|
+
# streamed reply keeps no raw body.
|
|
10
|
+
module ProviderStreaming
|
|
11
|
+
# Room for what a pause adds besides its messages (its retry, the
|
|
12
|
+
# attempt's usage).
|
|
13
|
+
PAUSE_MARGIN = 4096
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Withdraws the text a streamed step sent, for `reason`, when a call
|
|
18
|
+
# is about to replace it.
|
|
19
|
+
def withdraw_streamed(reason) = @step_stream&.withdraw(reason)
|
|
20
|
+
|
|
21
|
+
# The chat's reply and, for a streamed step, what Completion needs to
|
|
22
|
+
# read its facts. The stream's collection goes into `collected` too,
|
|
23
|
+
# for the facts of a call that fails part way. Requests over the wire
|
|
24
|
+
# are held on the step's budget ledger, if it has one, keyed by the
|
|
25
|
+
# attempt's `attempt_id` (`facts`).
|
|
26
|
+
def complete_chat(chat, model_reference, collected, facts = {})
|
|
27
|
+
return capture_batch_call(chat, model_reference) if @batch_capturing
|
|
28
|
+
|
|
29
|
+
entry = take_batch_result
|
|
30
|
+
delivered = deliver_batch_result(chat, entry) if entry
|
|
31
|
+
return delivered if delivered
|
|
32
|
+
|
|
33
|
+
remembered_completion(chat, model_reference, facts) do |resumed|
|
|
34
|
+
live_completion(chat, model_reference, collected, facts, resumed)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The call over the wire; `resumed` (RememberedCalls) says where a
|
|
39
|
+
# call a response memo kept part of goes on from.
|
|
40
|
+
def live_completion(chat, model_reference, collected, facts, resumed)
|
|
41
|
+
ledger = run_budget_ledger
|
|
42
|
+
return wire_completion(chat, model_reference, collected) unless ledger
|
|
43
|
+
|
|
44
|
+
start = resumed ? resumed.start : chat_message_count(chat)
|
|
45
|
+
holding = { pausable: budget_pausable?, rounds: resumed&.rounds || 0 }
|
|
46
|
+
Providers::RequestHolds.within(chat_provider(chat), ledger, facts[:attempt_id], **holding) do |holder|
|
|
47
|
+
held_completion(holder, chat, start) { wire_completion(chat, model_reference, collected) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A refusal of a tool loop's later round pauses the loop (its step
|
|
52
|
+
# retries for budget, BudgetPause): the error carries the messages to
|
|
53
|
+
# continue from (from `start`, a memo's kept rounds included). A loop
|
|
54
|
+
# whose messages cannot be kept goes on, unheld, from its tools'
|
|
55
|
+
# results (the chat keeps them), as when its step cannot pause.
|
|
56
|
+
def held_completion(holder, chat, start)
|
|
57
|
+
yield
|
|
58
|
+
rescue BudgetUnavailable => e
|
|
59
|
+
raise unless holder.pausable && holder.rounds > 1 && e.continuation.nil?
|
|
60
|
+
|
|
61
|
+
paused = paused_loop(e, chat, start)
|
|
62
|
+
raise paused if paused
|
|
63
|
+
|
|
64
|
+
holder.pausable = false
|
|
65
|
+
retry
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def paused_loop(error, chat, start)
|
|
69
|
+
continuation = Budget::Continuation.dump(@budget_continuation, chat.messages.drop(start || 0))
|
|
70
|
+
BudgetUnavailable.new(error.message, continuation:) if continuation && storable_pause?(continuation)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Whether the run's state, with the paused loop kept, stays within
|
|
74
|
+
# persistence_max_bytes: a pause it could not store would lose the
|
|
75
|
+
# loop's paid rounds, so the loop goes on instead.
|
|
76
|
+
def storable_pause?(continuation)
|
|
77
|
+
limit = Smith.config.persistence_max_bytes
|
|
78
|
+
return true unless limit
|
|
79
|
+
|
|
80
|
+
JSON.generate(to_state).bytesize + JSON.generate(continuation).bytesize + PAUSE_MARGIN <= limit
|
|
81
|
+
rescue StandardError
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def wire_completion(chat, model_reference, collected)
|
|
86
|
+
stream = @step_stream
|
|
87
|
+
return [chat.complete, nil] unless stream
|
|
88
|
+
|
|
89
|
+
stream.start(attempt: @step_attempt, model: model_reference.model_id, provider: model_reference.provider&.to_s)
|
|
90
|
+
response, rounds = streamed_completion(chat, stream, collected)
|
|
91
|
+
[response, { rounds:, output_limit: request_output_limit(chat, model_reference) }]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The chat's reply, its text sent as it comes (without <think> text),
|
|
95
|
+
# and the facts of each of its requests.
|
|
96
|
+
def streamed_completion(chat, stream, collected)
|
|
97
|
+
response = nil
|
|
98
|
+
thinking = Streaming::ThinkTags.new
|
|
99
|
+
rounds = Providers::StreamFacts.collect(chat_provider(chat)) do |collection|
|
|
100
|
+
collected << collection
|
|
101
|
+
response = chat.complete { |chunk| stream.delta(thinking.strip(chunk.content, collection.rounds.length)) }
|
|
102
|
+
end
|
|
103
|
+
[response, rounds]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The batch result as the chat's reply, and whether the request the
|
|
107
|
+
# call rendered is other than the one sent (a fact of its attempt). A
|
|
108
|
+
# result its call will not use (`on_request_change` :resend or :fail)
|
|
109
|
+
# is set aside, to be charged; :resend returns nil, and the call goes
|
|
110
|
+
# over the wire.
|
|
111
|
+
def deliver_batch_result(chat, entry)
|
|
112
|
+
delivery = @batch_delivery
|
|
113
|
+
response, changed = Providers::BatchExchange.deliver(chat_provider(chat), entry.result, entry.digest,
|
|
114
|
+
on_change: delivery.on_change) { chat.complete }
|
|
115
|
+
[response, nil, :batch, { request_changed: changed || nil }]
|
|
116
|
+
rescue BatchRequestChanged
|
|
117
|
+
delivery.set_aside(Attribution.current&.branch_key, entry)
|
|
118
|
+
raise unless delivery.on_change == :resend
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A batch step's call: the provider must have a batch interface (only
|
|
122
|
+
# Anthropic's, so far) and the agent no tools (a batch request is one
|
|
123
|
+
# turn). The request is thrown out of the call before it is sent.
|
|
124
|
+
def capture_batch_call(chat, model_reference)
|
|
125
|
+
provider = chat_provider(chat)
|
|
126
|
+
unless provider.respond_to?(:slug) && provider.slug.to_s == "anthropic"
|
|
127
|
+
raise BatchModeUnavailable, "#{model_reference.provider || "this provider"} has no batch interface in Smith"
|
|
128
|
+
end
|
|
129
|
+
raise BatchModeUnavailable, "a batch request is one turn: its agent cannot have tools" if chat_tools?(chat)
|
|
130
|
+
|
|
131
|
+
@batch_capture = Providers::BatchExchange::Exchange.new(provider:, holding: !run_budget_ledger.nil?)
|
|
132
|
+
Providers::BatchExchange.within(@batch_capture) { chat.complete }
|
|
133
|
+
raise BatchModeUnavailable, "the call did not reach its provider's request"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def chat_tools?(chat)
|
|
137
|
+
tools = chat.tools if chat.respond_to?(:tools)
|
|
138
|
+
tools.respond_to?(:empty?) && !tools.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The facts of a streamed call that failed, if it streamed.
|
|
142
|
+
def streamed_rounds(collected) = collected.first&.facts
|
|
143
|
+
|
|
144
|
+
def chat_provider(chat)
|
|
145
|
+
llm_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
|
|
146
|
+
llm_chat.instance_variable_get(:@provider)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def request_output_limit(chat, model_reference)
|
|
150
|
+
params = chat.params if chat.respond_to?(:params)
|
|
151
|
+
Providers::OutputLimit.current(model_reference.provider, params.is_a?(Hash) ? params : {}, model_reference)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -3,12 +3,33 @@
|
|
|
3
3
|
require "dry-struct"
|
|
4
4
|
|
|
5
5
|
require_relative "../types"
|
|
6
|
+
require_relative "../providers/response_facts"
|
|
6
7
|
|
|
7
8
|
module Smith
|
|
8
9
|
class Agent
|
|
10
|
+
# The usage one provider response reported. `input_tokens` counts only
|
|
11
|
+
# uncached input, which is how RubyLLM reports every provider: tokens read
|
|
12
|
+
# from or written to a prompt cache are `cache_read_tokens` and
|
|
13
|
+
# `cache_write_tokens`. `thinking_tokens` are already inside
|
|
14
|
+
# `output_tokens` and are reported, never added. The optional counts are
|
|
15
|
+
# nil when the provider did not report them.
|
|
9
16
|
class ProviderUsage < Dry::Struct
|
|
17
|
+
COUNT_READERS = {
|
|
18
|
+
cache_read_tokens: %i[cache_read_tokens cached_tokens],
|
|
19
|
+
cache_write_tokens: %i[cache_write_tokens cache_creation_tokens],
|
|
20
|
+
thinking_tokens: %i[thinking_tokens]
|
|
21
|
+
}.freeze
|
|
22
|
+
OPTIONAL_COUNTS = COUNT_READERS.keys.freeze
|
|
23
|
+
|
|
10
24
|
attribute :input_tokens, Types::Integer
|
|
11
25
|
attribute :output_tokens, Types::Integer
|
|
26
|
+
attribute :cache_read_tokens, Types::Integer.optional.default(nil)
|
|
27
|
+
attribute :cache_write_tokens, Types::Integer.optional.default(nil)
|
|
28
|
+
attribute :thinking_tokens, Types::Integer.optional.default(nil)
|
|
29
|
+
attribute :stop_reason, Types::String.optional.default(nil)
|
|
30
|
+
attribute :provider_message_id, Types::String.optional.default(nil)
|
|
31
|
+
# How the response was made: :stream, :batch, or nil (synchronous).
|
|
32
|
+
attribute :mode, Types::Symbol.optional.default(nil)
|
|
12
33
|
|
|
13
34
|
def self.from_message(message)
|
|
14
35
|
return unless message.respond_to?(:input_tokens) && message.respond_to?(:output_tokens)
|
|
@@ -17,11 +38,63 @@ module Smith
|
|
|
17
38
|
output_tokens = message.output_tokens
|
|
18
39
|
return unless input_tokens.is_a?(Integer) && output_tokens.is_a?(Integer)
|
|
19
40
|
|
|
20
|
-
new(input_tokens:, output_tokens
|
|
41
|
+
new(input_tokens:, output_tokens:, **optional_counts(message), **Providers::ResponseFacts.from_message(message))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.optional_counts(message)
|
|
45
|
+
COUNT_READERS.transform_values do |readers|
|
|
46
|
+
readers.lazy.filter_map { |reader| count_from(message, reader) }.first
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.count_from(message, reader)
|
|
51
|
+
return unless message.respond_to?(reader)
|
|
52
|
+
|
|
53
|
+
value = message.public_send(reader)
|
|
54
|
+
value if value.is_a?(Integer) && value >= 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The stop reason and message id are those of the last response, the
|
|
58
|
+
# one that ended the attempt.
|
|
59
|
+
def self.sum(usages)
|
|
60
|
+
return if usages.empty?
|
|
61
|
+
|
|
62
|
+
counts = OPTIONAL_COUNTS.to_h do |name|
|
|
63
|
+
values = usages.filter_map(&name)
|
|
64
|
+
[name, values.empty? ? nil : values.sum]
|
|
65
|
+
end
|
|
66
|
+
new(input_tokens: usages.sum(&:input_tokens), output_tokens: usages.sum(&:output_tokens), **counts,
|
|
67
|
+
stop_reason: usages.last.stop_reason, provider_message_id: usages.last.provider_message_id,
|
|
68
|
+
mode: usages.last.mode)
|
|
69
|
+
end
|
|
70
|
+
private_class_method :optional_counts, :count_from
|
|
71
|
+
|
|
72
|
+
# This usage with the stop reason and message id of `facts`, a
|
|
73
|
+
# ResponseFacts result, in place of its own: a tool loop's summed
|
|
74
|
+
# usage carries its final response's facts even when that response
|
|
75
|
+
# reported no usage.
|
|
76
|
+
def with_response_facts(facts)
|
|
77
|
+
self.class.new(**to_h.except(:stop_reason, :provider_message_id), **facts)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# This usage as a streamed response's, with the facts its stream
|
|
81
|
+
# carried (Providers::StreamFacts).
|
|
82
|
+
def streamed(facts) = with_response_facts(facts).new(mode: :stream)
|
|
83
|
+
|
|
84
|
+
# Optional members are omitted when nil, so usage without cache or
|
|
85
|
+
# response details keeps its earlier shape.
|
|
86
|
+
def to_h
|
|
87
|
+
super.compact
|
|
88
|
+
end
|
|
89
|
+
alias to_hash to_h
|
|
90
|
+
|
|
91
|
+
# Every input token the provider processed, cached or not.
|
|
92
|
+
def total_input_tokens
|
|
93
|
+
input_tokens + (cache_read_tokens || 0) + (cache_write_tokens || 0)
|
|
21
94
|
end
|
|
22
95
|
|
|
23
96
|
def total_tokens
|
|
24
|
-
|
|
97
|
+
total_input_tokens + output_tokens
|
|
25
98
|
end
|
|
26
99
|
end
|
|
27
100
|
end
|
|
@@ -32,7 +32,9 @@ module Smith
|
|
|
32
32
|
attempt_kind: attempt_kind,
|
|
33
33
|
recorded_at: Time.now.utc.iso8601(6),
|
|
34
34
|
attempt_id: attempt_id,
|
|
35
|
-
**ambient_attribution_fields
|
|
35
|
+
**ambient_attribution_fields,
|
|
36
|
+
**Workflow::AgentResult::USAGE_DETAIL_MEMBERS.to_h { |member| [member, agent_result.public_send(member)] },
|
|
37
|
+
mode: agent_result.mode
|
|
36
38
|
)
|
|
37
39
|
end
|
|
38
40
|
|
|
@@ -52,7 +54,7 @@ module Smith
|
|
|
52
54
|
|
|
53
55
|
def accumulate_usage(agent_result, entry)
|
|
54
56
|
@usage_mutex.synchronize do
|
|
55
|
-
@total_tokens = (@total_tokens || 0) + agent_result.
|
|
57
|
+
@total_tokens = (@total_tokens || 0) + agent_result.total_tokens
|
|
56
58
|
@total_cost = (@total_cost || 0.0) + (agent_result.cost || 0.0)
|
|
57
59
|
@usage_entries << entry
|
|
58
60
|
end
|
|
@@ -16,6 +16,8 @@ module Smith
|
|
|
16
16
|
data: {
|
|
17
17
|
input_tokens: agent_result.input_tokens,
|
|
18
18
|
output_tokens: agent_result.output_tokens,
|
|
19
|
+
cache_read_tokens: agent_result.cache_read_tokens,
|
|
20
|
+
cache_write_tokens: agent_result.cache_write_tokens,
|
|
19
21
|
model: agent_result.model_used,
|
|
20
22
|
provider: agent_result.provider_used
|
|
21
23
|
}.compact
|
|
@@ -30,7 +32,9 @@ module Smith
|
|
|
30
32
|
# traces is not a spend total. Unpriced usage emits nothing, and the
|
|
31
33
|
# caller gates out partially metered or partially priced invocations
|
|
32
34
|
# so an incomplete figure is never presented as the invocation cost.
|
|
33
|
-
|
|
35
|
+
# `cache_unpriced: true` says cache tokens were priced at 0 because the
|
|
36
|
+
# catalog has no rate for them.
|
|
37
|
+
def emit_cost_trace(agent_result, invocation_cost, cache_unpriced: false)
|
|
34
38
|
return if invocation_cost.nil?
|
|
35
39
|
|
|
36
40
|
Smith::Trace.record(
|
|
@@ -40,7 +44,10 @@ module Smith
|
|
|
40
44
|
model: agent_result.model_used,
|
|
41
45
|
provider: agent_result.provider_used,
|
|
42
46
|
input_tokens: agent_result.input_tokens,
|
|
43
|
-
output_tokens: agent_result.output_tokens
|
|
47
|
+
output_tokens: agent_result.output_tokens,
|
|
48
|
+
cache_read_tokens: agent_result.cache_read_tokens,
|
|
49
|
+
cache_write_tokens: agent_result.cache_write_tokens,
|
|
50
|
+
cache_unpriced: (true if cache_unpriced)
|
|
44
51
|
}.compact
|
|
45
52
|
)
|
|
46
53
|
end
|