smith-agents 0.7.0 → 0.8.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 +226 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +22 -1
- data/docs/PERSISTENCE.md +27 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +233 -2
- data/lib/smith/agent/chat_construction.rb +112 -0
- data/lib/smith/agent/completion.rb +46 -0
- data/lib/smith/agent/completion_usage_recording.rb +25 -0
- data/lib/smith/agent/dynamic_configuration.rb +69 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -0
- data/lib/smith/agent/invocation_preparation.rb +104 -0
- data/lib/smith/agent/lifecycle.rb +7 -276
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +28 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +130 -0
- data/lib/smith/agent/provider_failure_handling.rb +93 -0
- data/lib/smith/agent/provider_usage.rb +28 -0
- data/lib/smith/agent/reserved_input_bridge.rb +40 -0
- data/lib/smith/agent/usage_entry_recording.rb +43 -0
- data/lib/smith/agent/usage_tracking.rb +113 -0
- data/lib/smith/agent.rb +29 -197
- data/lib/smith/context/observation_masking.rb +28 -4
- data/lib/smith/context/session.rb +7 -2
- data/lib/smith/diagnostic_text.rb +67 -0
- data/lib/smith/doctor/checks/configuration.rb +16 -3
- data/lib/smith/doctor/checks/models_registry.rb +75 -17
- data/lib/smith/errors.rb +34 -1
- data/lib/smith/models/ambiguous_profile_error.rb +9 -0
- data/lib/smith/models/collision_error.rb +9 -0
- data/lib/smith/models/inference.rb +14 -0
- data/lib/smith/models/normalizer.rb +11 -67
- data/lib/smith/models/provider_qualified_registry.rb +101 -0
- data/lib/smith/models/tool_routing.rb +140 -0
- data/lib/smith/models.rb +24 -78
- data/lib/smith/persisted_failure_invalid.rb +7 -0
- data/lib/smith/pricing.rb +138 -17
- data/lib/smith/pricing_configuration_error.rb +7 -0
- data/lib/smith/provider_permanent_failure.rb +16 -0
- data/lib/smith/tool/argument_container_reader.rb +97 -0
- data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
- data/lib/smith/tool/argument_snapshot.rb +19 -0
- data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
- data/lib/smith/tool/argument_snapshot_result.rb +15 -0
- data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
- data/lib/smith/tool/bounded_completion_context.rb +131 -0
- data/lib/smith/tool/bounded_completion_controls.rb +113 -0
- data/lib/smith/tool/bounded_completion_guard.rb +101 -0
- data/lib/smith/tool/bounded_completion_installation.rb +20 -0
- data/lib/smith/tool/bounded_completion_state.rb +57 -0
- data/lib/smith/tool/budget_enforcement.rb +15 -7
- data/lib/smith/tool/call_admission.rb +44 -0
- data/lib/smith/tool/call_allowance.rb +101 -28
- data/lib/smith/tool/call_allowance_counter.rb +43 -0
- data/lib/smith/tool/call_batch.rb +47 -0
- data/lib/smith/tool/call_budget.rb +51 -0
- data/lib/smith/tool/call_reservation.rb +42 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
- data/lib/smith/tool/chat_execution_context.rb +7 -88
- data/lib/smith/tool/execution_authority.rb +42 -0
- data/lib/smith/tool/execution_authorization.rb +17 -0
- data/lib/smith/tool/execution_batch.rb +100 -0
- data/lib/smith/tool/execution_batch_admission.rb +56 -0
- data/lib/smith/tool/execution_batch_builder.rb +122 -0
- data/lib/smith/tool/execution_batch_collection.rb +70 -0
- data/lib/smith/tool/execution_batch_invocations.rb +28 -0
- data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
- data/lib/smith/tool/execution_batch_registry.rb +99 -0
- data/lib/smith/tool/execution_batch_requests.rb +59 -0
- data/lib/smith/tool/execution_batch_source_call.rb +36 -0
- data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
- data/lib/smith/tool/execution_batch_sources.rb +32 -0
- data/lib/smith/tool/execution_batch_state.rb +110 -0
- data/lib/smith/tool/execution_dispatch.rb +78 -0
- data/lib/smith/tool/execution_failure_handling.rb +101 -0
- data/lib/smith/tool/execution_lifecycle.rb +28 -0
- data/lib/smith/tool/execution_tracker.rb +20 -0
- data/lib/smith/tool/fail_fast_completion.rb +24 -0
- data/lib/smith/tool/graceful_completion.rb +20 -0
- data/lib/smith/tool/invocation.rb +27 -0
- data/lib/smith/tool/invocation_request.rb +37 -0
- data/lib/smith/tool/invocation_sequence.rb +26 -0
- data/lib/smith/tool/legacy_call_allowance.rb +57 -0
- data/lib/smith/tool/scoped_context.rb +60 -8
- data/lib/smith/tool.rb +71 -12
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/agent_result.rb +4 -3
- data/lib/smith/workflow/artifact_integration.rb +2 -1
- data/lib/smith/workflow/composite/effects.rb +3 -1
- data/lib/smith/workflow/deadline_enforcement.rb +20 -2
- data/lib/smith/workflow/execution.rb +2 -9
- data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
- data/lib/smith/workflow/failure_reconstructor.rb +79 -0
- data/lib/smith/workflow/failure_record.rb +139 -0
- data/lib/smith/workflow/failure_record_restore.rb +145 -0
- data/lib/smith/workflow/failure_record_text.rb +83 -0
- data/lib/smith/workflow/failure_record_validator.rb +113 -0
- data/lib/smith/workflow/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/persistence.rb +22 -19
- data/lib/smith/workflow/retry_execution.rb +5 -1
- data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +12 -2
- data/lib/smith/workflow.rb +17 -130
- data/lib/smith.rb +3 -0
- metadata +78 -11
|
@@ -15,7 +15,12 @@ module Smith
|
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
17
|
def run(report)
|
|
18
|
-
uncovered =
|
|
18
|
+
uncovered, ambiguous = classified_model_references
|
|
19
|
+
report_ambiguous_models(report, ambiguous)
|
|
20
|
+
report_model_coverage(report, uncovered)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def report_model_coverage(report, uncovered)
|
|
19
24
|
if uncovered.empty?
|
|
20
25
|
report.add(
|
|
21
26
|
name: "models.coverage",
|
|
@@ -36,23 +41,65 @@ module Smith
|
|
|
36
41
|
end
|
|
37
42
|
end
|
|
38
43
|
|
|
44
|
+
# An unqualified agent model id registered under more than one
|
|
45
|
+
# provider cannot resolve to a single profile: chat construction
|
|
46
|
+
# fails closed with AmbiguousProfileError. Doctor must report that
|
|
47
|
+
# configuration instead of crashing on it.
|
|
48
|
+
def report_ambiguous_models(report, ambiguous)
|
|
49
|
+
return if ambiguous.empty?
|
|
50
|
+
|
|
51
|
+
descriptions = ambiguous.map { |reference| ambiguous_reference_description(reference) }
|
|
52
|
+
report.add(
|
|
53
|
+
name: "models.ambiguity",
|
|
54
|
+
status: :fail,
|
|
55
|
+
message: "#{ambiguous.size} agent model id(s) match profiles from multiple registered providers",
|
|
56
|
+
detail: "Ambiguous: #{descriptions.join("; ")}. Chat construction fails closed for " \
|
|
57
|
+
"these agents until each declares an explicit provider (for example " \
|
|
58
|
+
"model \"gpt-5\", provider: :openai) selecting exactly one registered profile."
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ambiguous_reference_description(reference)
|
|
63
|
+
"#{reference.model_id} (providers: #{registered_providers_for(reference).join(", ")})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def registered_providers_for(reference)
|
|
67
|
+
Smith::Models.all
|
|
68
|
+
.select { |profile| profile.model_id == reference.model_id }
|
|
69
|
+
.map { |profile| profile.provider.to_s }
|
|
70
|
+
end
|
|
71
|
+
|
|
39
72
|
# Walk Smith::Agent::Registry. For each agent, extract every static
|
|
40
73
|
# model id Smith can know at boot: the primary `model "..."` value and
|
|
41
74
|
# any static fallback models. Block-form primary models are skipped
|
|
42
75
|
# because they resolve per-attempt, but their static fallbacks still
|
|
43
76
|
# need coverage checks.
|
|
44
77
|
# Check whether find_or_infer returns a custom (non-default)
|
|
45
|
-
# Profile
|
|
46
|
-
# rule matched.
|
|
47
|
-
def
|
|
48
|
-
return [] unless defined?(Smith::Agent::Registry)
|
|
78
|
+
# Profile, meaning either an explicit override or an inference
|
|
79
|
+
# rule matched. Returns [uncovered, ambiguous] reference lists.
|
|
80
|
+
def classified_model_references
|
|
81
|
+
return [[], []] unless defined?(Smith::Agent::Registry)
|
|
49
82
|
|
|
50
|
-
|
|
83
|
+
uncovered = []
|
|
84
|
+
ambiguous = []
|
|
85
|
+
static_model_references.uniq(&:key).each do |reference|
|
|
86
|
+
case model_coverage(reference)
|
|
87
|
+
when :uncovered then uncovered << reference
|
|
88
|
+
when :ambiguous then ambiguous << reference
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
[uncovered, ambiguous]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def model_coverage(reference)
|
|
95
|
+
covered_model?(reference) ? :covered : :uncovered
|
|
96
|
+
rescue Smith::Models::AmbiguousProfileError
|
|
97
|
+
:ambiguous
|
|
51
98
|
end
|
|
52
99
|
|
|
53
|
-
def
|
|
54
|
-
Smith::Agent::Registry.each.with_object([]) do |(_key, agent),
|
|
55
|
-
|
|
100
|
+
def static_model_references
|
|
101
|
+
Smith::Agent::Registry.each.with_object([]) do |(_key, agent), references|
|
|
102
|
+
references.concat(static_model_references_for(agent)) if inspectable_agent?(agent)
|
|
56
103
|
end
|
|
57
104
|
end
|
|
58
105
|
|
|
@@ -60,16 +107,27 @@ module Smith
|
|
|
60
107
|
agent.is_a?(Class) && agent.respond_to?(:chat_kwargs)
|
|
61
108
|
end
|
|
62
109
|
|
|
63
|
-
def
|
|
64
|
-
[
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
110
|
+
def static_model_references_for(agent)
|
|
111
|
+
primary = agent.chat_kwargs[:model]
|
|
112
|
+
references = Array(agent.respond_to?(:fallback_models) ? agent.fallback_models : nil).dup
|
|
113
|
+
if primary
|
|
114
|
+
references.unshift(
|
|
115
|
+
Smith::Agent::ModelReference.coerce(primary, provider: agent.chat_kwargs[:provider])
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
references
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def covered_model?(reference)
|
|
122
|
+
Smith::Models.find(reference.model_id, provider: reference.provider) ||
|
|
123
|
+
inferred_profile_matches?(reference)
|
|
68
124
|
end
|
|
69
125
|
|
|
70
|
-
def
|
|
71
|
-
Smith::Models
|
|
72
|
-
|
|
126
|
+
def inferred_profile_matches?(reference)
|
|
127
|
+
return false unless defined?(Smith::Models::Inference)
|
|
128
|
+
|
|
129
|
+
profile = Smith::Models::Inference.profile_for(reference.model_id)
|
|
130
|
+
profile && (reference.provider.nil? || profile.provider.to_sym == reference.provider)
|
|
73
131
|
end
|
|
74
132
|
end
|
|
75
133
|
end
|
data/lib/smith/errors.rb
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "error"
|
|
4
|
+
require_relative "persisted_failure_invalid"
|
|
5
|
+
require_relative "pricing_configuration_error"
|
|
6
|
+
require_relative "provider_permanent_failure"
|
|
7
|
+
require_relative "tool_capture_failed"
|
|
8
|
+
require_relative "tool_execution_not_admitted"
|
|
9
|
+
require_relative "tool_failure_notification_failed"
|
|
4
10
|
|
|
5
11
|
module Smith
|
|
6
12
|
# Classification surface for host retry policies. Smith owns the
|
|
7
13
|
# answer to "should the workflow attempt be retried?" so consumers
|
|
8
14
|
# don't reimplement the case statement in every Execution / Job.
|
|
9
15
|
module Errors
|
|
16
|
+
MODULE_MATCH = Module.instance_method(:===)
|
|
17
|
+
private_constant :MODULE_MATCH
|
|
18
|
+
|
|
10
19
|
# Returns true when the host should retry the workflow attempt.
|
|
11
20
|
# AgentError + DeadlineExceeded are always retryable.
|
|
12
21
|
# DeterministicStepFailure + ToolGuardrailFailed honor their
|
|
@@ -16,7 +25,7 @@ module Smith
|
|
|
16
25
|
return false if error.nil?
|
|
17
26
|
|
|
18
27
|
composite_failure = defined?(Smith::Workflow::Composite::BranchFailure) &&
|
|
19
|
-
|
|
28
|
+
MODULE_MATCH.bind_call(Smith::Workflow::Composite::BranchFailure, error)
|
|
20
29
|
return false if composite_failure
|
|
21
30
|
|
|
22
31
|
case error
|
|
@@ -29,6 +38,27 @@ module Smith
|
|
|
29
38
|
end
|
|
30
39
|
end
|
|
31
40
|
|
|
41
|
+
def self.retry_forbidden?(error)
|
|
42
|
+
return false if error.nil?
|
|
43
|
+
|
|
44
|
+
retry_forbidden_classes.any? { |error_class| MODULE_MATCH.bind_call(error_class, error) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.retry_forbidden_class?(error_class)
|
|
48
|
+
retry_forbidden_classes.any? { |forbidden| error_class <= forbidden }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.retry_forbidden_classes
|
|
52
|
+
@retry_forbidden_classes ||= [
|
|
53
|
+
Smith::ToolCaptureFailed,
|
|
54
|
+
Smith::ToolOutcomeUncertain,
|
|
55
|
+
Smith::ToolExecutionNotAdmitted,
|
|
56
|
+
Smith::ToolFailureNotificationFailed,
|
|
57
|
+
Smith::BoundedCompletionError,
|
|
58
|
+
Smith::PersistedFailureInvalid
|
|
59
|
+
].freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
32
62
|
# Always-retryable error classes for explicit ActiveJob retry_on
|
|
33
63
|
# allow-lists. Excludes the retryable-bearing families because
|
|
34
64
|
# their retryability is per-raise, not per-class.
|
|
@@ -52,6 +82,9 @@ module Smith
|
|
|
52
82
|
end
|
|
53
83
|
|
|
54
84
|
class ToolPolicyDenied < Error; end
|
|
85
|
+
class ToolDispatchRejected < Error; end
|
|
86
|
+
class BoundedCompletionError < Error; end
|
|
87
|
+
class ToolOutcomeUncertain < Error; end
|
|
55
88
|
class AgentError < Error; end
|
|
56
89
|
|
|
57
90
|
class BlankAgentOutputError < AgentError
|
|
@@ -82,6 +82,20 @@ module Smith
|
|
|
82
82
|
[
|
|
83
83
|
# ----- Anthropic -----
|
|
84
84
|
|
|
85
|
+
# Claude 5+ families (Fable/Mythos/Opus/Sonnet/Haiku): adaptive
|
|
86
|
+
# thinking, no temperature accepted (the API rejects an explicit
|
|
87
|
+
# temperature with a 400).
|
|
88
|
+
Rule.new(
|
|
89
|
+
provider: :anthropic,
|
|
90
|
+
matcher: lambda { |id|
|
|
91
|
+
m = id.match(/\Aclaude-(?:fable|mythos|opus|sonnet|haiku)-(\d+)/)
|
|
92
|
+
m && m[1].to_i >= 5
|
|
93
|
+
},
|
|
94
|
+
thinking_shape: :adaptive,
|
|
95
|
+
accepts_temperature: false,
|
|
96
|
+
tools_with_thinking_native: true,
|
|
97
|
+
tools_with_thinking_route: nil
|
|
98
|
+
),
|
|
85
99
|
# Opus 4.7+: adaptive thinking, no temperature accepted.
|
|
86
100
|
Rule.new(
|
|
87
101
|
provider: :anthropic,
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "dry-initializer"
|
|
4
4
|
|
|
5
|
+
require_relative "tool_routing"
|
|
6
|
+
|
|
5
7
|
module Smith
|
|
6
8
|
module Models
|
|
7
9
|
# Per-chat-construction request shaper. Mutates a RubyLLM::Chat
|
|
@@ -96,73 +98,11 @@ module Smith
|
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
def normalize_tools_routing
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return if tools.empty?
|
|
105
|
-
return unless thinking_active?
|
|
106
|
-
|
|
107
|
-
return if profile.tools_with_thinking_native
|
|
108
|
-
|
|
109
|
-
if profile.tools_with_thinking_route == :responses &&
|
|
110
|
-
Smith.config.openai_api_mode == :auto
|
|
111
|
-
merge_params(openai_api_mode: :responses)
|
|
112
|
-
@decisions << Decision.new(kind: :routed_via_responses, model_id: profile.model_id, detail: nil)
|
|
113
|
-
return
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
drop_incompatible_tools(tools)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def thinking_active?
|
|
120
|
-
thinking = chat.instance_variable_get(:@thinking)
|
|
121
|
-
return true if thinking&.enabled?
|
|
122
|
-
|
|
123
|
-
# Also active if we already translated to adaptive (in which case
|
|
124
|
-
# @thinking is nil but params carry the thinking spec).
|
|
125
|
-
params = chat.instance_variable_get(:@params) || {}
|
|
126
|
-
params.key?(:thinking) || params.key?(:reasoning) || params.key?(:reasoning_effort)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def drop_incompatible_tools(tools)
|
|
130
|
-
effective_endpoint = effective_endpoint_for_compatibility
|
|
131
|
-
incompatible = tools.reject do |tool|
|
|
132
|
-
spec = tool.class.respond_to?(:compatible_with_spec) ? tool.class.compatible_with_spec : nil
|
|
133
|
-
if defined?(Smith::Tool::Compatibility)
|
|
134
|
-
Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: effective_endpoint)
|
|
135
|
-
else
|
|
136
|
-
true
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
return if incompatible.empty?
|
|
140
|
-
|
|
141
|
-
retained = tools - incompatible
|
|
142
|
-
chat.with_tools(*retained, replace: true)
|
|
143
|
-
|
|
144
|
-
incompatible.each do |tool|
|
|
145
|
-
@decisions << Decision.new(
|
|
146
|
-
kind: :tool_dropped,
|
|
147
|
-
model_id: profile.model_id,
|
|
148
|
-
detail: { tool: tool.class.name }
|
|
149
|
-
)
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
# Profile.endpoint_mode reports the INTENDED endpoint (per the
|
|
154
|
-
# inference rule). Smith.config.openai_api_mode policy can downgrade
|
|
155
|
-
# the EFFECTIVE endpoint — e.g., a profile with route :responses
|
|
156
|
-
# actually uses :chat_completions when openai_api_mode is :off.
|
|
157
|
-
# The compatibility check needs the effective endpoint to make
|
|
158
|
-
# the right drop/keep decision.
|
|
159
|
-
def effective_endpoint_for_compatibility
|
|
160
|
-
if profile.tools_with_thinking_route == :responses &&
|
|
161
|
-
Smith.config.openai_api_mode != :auto
|
|
162
|
-
:chat_completions
|
|
163
|
-
else
|
|
164
|
-
profile.endpoint_mode
|
|
165
|
-
end
|
|
101
|
+
ToolRouting.new(
|
|
102
|
+
chat: chat,
|
|
103
|
+
profile: profile,
|
|
104
|
+
decision_recorder: method(:record_decision)
|
|
105
|
+
).call
|
|
166
106
|
end
|
|
167
107
|
|
|
168
108
|
# with_params REPLACES @params in RubyLLM (chat.rb:96), so the
|
|
@@ -173,6 +113,10 @@ module Smith
|
|
|
173
113
|
chat.with_params(**existing, **new_params)
|
|
174
114
|
end
|
|
175
115
|
|
|
116
|
+
def record_decision(kind, model_id, detail)
|
|
117
|
+
@decisions << Decision.new(kind: kind, model_id: model_id, detail: detail)
|
|
118
|
+
end
|
|
119
|
+
|
|
176
120
|
def emit_trace
|
|
177
121
|
return if @decisions.empty?
|
|
178
122
|
return unless defined?(Smith::Trace)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Models
|
|
7
|
+
module ProviderQualifiedRegistry
|
|
8
|
+
def self.extended(registry)
|
|
9
|
+
registry.instance_variable_set(:@registry_monitor, Monitor.new)
|
|
10
|
+
registry.instance_variable_set(:@registry_index, {})
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def find(model_id, provider: nil)
|
|
14
|
+
registry_monitor.synchronize do
|
|
15
|
+
registered_keys = registered_keys_for(model_id, provider:)
|
|
16
|
+
return nil if registered_keys.empty?
|
|
17
|
+
|
|
18
|
+
reject_ambiguous_lookup!(model_id, registered_keys)
|
|
19
|
+
resolve(registered_keys.first)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register(profile)
|
|
24
|
+
registry_monitor.synchronize do
|
|
25
|
+
profile = normalized_profile(profile)
|
|
26
|
+
key = registry_key(profile.model_id, profile.provider)
|
|
27
|
+
existing = key?(key) ? resolve(key) : nil
|
|
28
|
+
|
|
29
|
+
return profile if existing == profile
|
|
30
|
+
|
|
31
|
+
raise_collision!(key, existing, profile) if existing
|
|
32
|
+
|
|
33
|
+
super(key, profile)
|
|
34
|
+
index_profile(profile, key)
|
|
35
|
+
profile
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def all
|
|
40
|
+
registry_monitor.synchronize do
|
|
41
|
+
keys.map { |key| resolve(key) }.sort_by do |profile|
|
|
42
|
+
[profile.model_id, profile.provider.to_s]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clear!
|
|
48
|
+
registry_monitor.synchronize do
|
|
49
|
+
@_container&.clear
|
|
50
|
+
@registry_index = {}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :registry_monitor, :registry_index
|
|
57
|
+
|
|
58
|
+
def registered_keys_for(model_id, provider:)
|
|
59
|
+
normalized_id = normalize_key(model_id)
|
|
60
|
+
return registry_index.fetch(normalized_id, []) unless provider
|
|
61
|
+
|
|
62
|
+
key = registry_key(normalized_id, provider)
|
|
63
|
+
key?(key) ? [key] : []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def registry_key(model_id, provider)
|
|
67
|
+
[provider.to_sym, normalize_key(model_id)]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reject_ambiguous_lookup!(model_id, registered_keys)
|
|
71
|
+
return unless registered_keys.length > 1
|
|
72
|
+
|
|
73
|
+
providers = registered_keys.map { |key| resolve(key).provider.to_s }.sort
|
|
74
|
+
raise AmbiguousProfileError,
|
|
75
|
+
"model #{normalize_key(model_id).inspect} has profiles for multiple providers: " \
|
|
76
|
+
"#{providers.join(", ")}; pass provider:"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def index_profile(profile, key)
|
|
80
|
+
model_id = normalize_key(profile.model_id)
|
|
81
|
+
registry_index[model_id] = [*registry_index.fetch(model_id, []), key].uniq.freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def normalized_profile(profile)
|
|
85
|
+
Profile.new(
|
|
86
|
+
**profile.to_h,
|
|
87
|
+
model_id: normalize_key(profile.model_id).freeze,
|
|
88
|
+
provider: profile.provider.to_sym
|
|
89
|
+
)
|
|
90
|
+
rescue NoMethodError, TypeError
|
|
91
|
+
raise ArgumentError, "model profile must expose model_id, provider, and profile attributes"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def raise_collision!(key, existing, replacement)
|
|
95
|
+
raise CollisionError,
|
|
96
|
+
"model profile collision for #{key.inspect}: existing #{existing.inspect}, " \
|
|
97
|
+
"replacement #{replacement.inspect}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Models
|
|
7
|
+
class ToolRouting
|
|
8
|
+
TOOL_ENDPOINTS = %i[chat_completions responses].freeze
|
|
9
|
+
|
|
10
|
+
private_constant :TOOL_ENDPOINTS
|
|
11
|
+
|
|
12
|
+
extend Dry::Initializer
|
|
13
|
+
|
|
14
|
+
option :chat
|
|
15
|
+
option :profile
|
|
16
|
+
option :decision_recorder
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return unless chat.respond_to?(:tools)
|
|
20
|
+
|
|
21
|
+
tools = chat.tools.values
|
|
22
|
+
return if tools.empty?
|
|
23
|
+
|
|
24
|
+
route_tools_with_thinking if thinking_active?
|
|
25
|
+
route_tools_for_compatibility(tools)
|
|
26
|
+
drop_incompatible_tools(tools)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def route_tools_with_thinking
|
|
32
|
+
return unless profile.provider == :openai
|
|
33
|
+
return if profile.tools_with_thinking_native
|
|
34
|
+
return unless profile.tools_with_thinking_route == :responses
|
|
35
|
+
return unless Smith.config.openai_api_mode == :auto
|
|
36
|
+
|
|
37
|
+
route_via_responses
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def route_tools_for_compatibility(tools)
|
|
41
|
+
return unless profile.provider == :openai
|
|
42
|
+
return unless Smith.config.openai_api_mode == :auto
|
|
43
|
+
return if responses_routed?
|
|
44
|
+
|
|
45
|
+
forced_endpoint = forced_tool_endpoint(tools)
|
|
46
|
+
return if forced_endpoint == :chat_completions
|
|
47
|
+
return unless forced_endpoint == :responses ||
|
|
48
|
+
compatible_tool_count(tools, :responses) > compatible_tool_count(tools, :chat_completions)
|
|
49
|
+
|
|
50
|
+
route_via_responses
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def route_via_responses
|
|
54
|
+
return if responses_routed?
|
|
55
|
+
|
|
56
|
+
merge_params(openai_api_mode: :responses)
|
|
57
|
+
record(:routed_via_responses)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def drop_incompatible_tools(tools)
|
|
61
|
+
endpoint = responses_routed? ? :responses : :chat_completions
|
|
62
|
+
incompatible = tools.reject { |tool| tool_compatible_with?(tool, endpoint) }
|
|
63
|
+
return if incompatible.empty?
|
|
64
|
+
|
|
65
|
+
remaining = tools - incompatible
|
|
66
|
+
forced_choice = reset_invalid_tool_choice(remaining)
|
|
67
|
+
chat.with_tools(*remaining, replace: true)
|
|
68
|
+
incompatible.each_with_index do |tool, index|
|
|
69
|
+
record(:tool_dropped, dropped_tool_detail(tool, forced_choice, first: index.zero?))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def dropped_tool_detail(tool, forced_choice, first:)
|
|
74
|
+
detail = { tool: tool.class.name }
|
|
75
|
+
if forced_choice && (tool.name.to_sym == forced_choice.to_sym || (forced_choice == :required && first))
|
|
76
|
+
detail[:forced_choice_reset] = forced_choice
|
|
77
|
+
end
|
|
78
|
+
detail
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def forced_tool_endpoint(tools)
|
|
82
|
+
choice = chat.tool_prefs[:choice]&.to_sym
|
|
83
|
+
return if %i[auto none required].include?(choice)
|
|
84
|
+
|
|
85
|
+
tool = tools.find { |candidate| candidate.name.to_sym == choice }
|
|
86
|
+
return unless tool
|
|
87
|
+
|
|
88
|
+
compatible_endpoints = TOOL_ENDPOINTS.select { |endpoint| tool_compatible_with?(tool, endpoint) }
|
|
89
|
+
compatible_endpoints.first if compatible_endpoints.one?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def reset_invalid_tool_choice(remaining)
|
|
93
|
+
choice = chat.tool_prefs[:choice]
|
|
94
|
+
return unless choice
|
|
95
|
+
return if %i[auto none].include?(choice)
|
|
96
|
+
return if choice == :required && remaining.any?
|
|
97
|
+
return if remaining.any? { |tool| tool.name.to_sym == choice.to_sym }
|
|
98
|
+
|
|
99
|
+
chat.tool_prefs[:choice] = nil
|
|
100
|
+
choice
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def compatible_tool_count(tools, endpoint)
|
|
104
|
+
tools.count { |tool| tool_compatible_with?(tool, endpoint) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def tool_compatible_with?(tool, endpoint)
|
|
108
|
+
return true unless defined?(Smith::Tool::Compatibility)
|
|
109
|
+
|
|
110
|
+
spec = tool.class.respond_to?(:compatible_with_spec) ? tool.class.compatible_with_spec : nil
|
|
111
|
+
Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: endpoint)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def thinking_active?
|
|
115
|
+
thinking = chat.instance_variable_get(:@thinking)
|
|
116
|
+
return true if thinking&.enabled?
|
|
117
|
+
|
|
118
|
+
current_params.key?(:thinking) || current_params.key?(:reasoning) ||
|
|
119
|
+
current_params.key?(:reasoning_effort)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def responses_routed?
|
|
123
|
+
mode = current_params[:openai_api_mode] || current_params["openai_api_mode"]
|
|
124
|
+
mode.to_s == "responses"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def current_params
|
|
128
|
+
chat.instance_variable_get(:@params) || {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def merge_params(**new_params)
|
|
132
|
+
chat.with_params(**current_params, **new_params)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def record(kind, detail = nil)
|
|
136
|
+
decision_recorder.call(kind, profile.model_id, detail)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|