smith-agents 0.4.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 +7 -0
- data/CHANGELOG.md +139 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/Rakefile +14 -0
- data/UPSTREAM_PROPOSAL.md +141 -0
- data/docs/CONFIGURATION.md +123 -0
- data/docs/PATTERNS.md +492 -0
- data/docs/PERSISTENCE.md +169 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
- data/docs/workflow_claim.md +58 -0
- data/exe/smith +7 -0
- data/lib/generators/smith/install/install_generator.rb +22 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
- data/lib/smith/agent/lifecycle.rb +264 -0
- data/lib/smith/agent/registry.rb +128 -0
- data/lib/smith/agent.rb +259 -0
- data/lib/smith/artifacts/file.rb +59 -0
- data/lib/smith/artifacts/memory.rb +75 -0
- data/lib/smith/artifacts/scoped_store.rb +29 -0
- data/lib/smith/artifacts.rb +5 -0
- data/lib/smith/budget/ledger.rb +42 -0
- data/lib/smith/budget.rb +5 -0
- data/lib/smith/cli.rb +82 -0
- data/lib/smith/context/observation_masking.rb +19 -0
- data/lib/smith/context/session.rb +42 -0
- data/lib/smith/context/state_injection.rb +24 -0
- data/lib/smith/context.rb +61 -0
- data/lib/smith/doctor/check.rb +12 -0
- data/lib/smith/doctor/checks/baseline.rb +84 -0
- data/lib/smith/doctor/checks/configuration.rb +56 -0
- data/lib/smith/doctor/checks/durability.rb +103 -0
- data/lib/smith/doctor/checks/live.rb +55 -0
- data/lib/smith/doctor/checks/models_registry.rb +66 -0
- data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
- data/lib/smith/doctor/checks/persistence.rb +99 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
- data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
- data/lib/smith/doctor/checks/rails.rb +39 -0
- data/lib/smith/doctor/checks/serialization.rb +78 -0
- data/lib/smith/doctor/installer.rb +103 -0
- data/lib/smith/doctor/printer.rb +62 -0
- data/lib/smith/doctor/report.rb +39 -0
- data/lib/smith/doctor.rb +53 -0
- data/lib/smith/errors.rb +191 -0
- data/lib/smith/event.rb +11 -0
- data/lib/smith/events/.keep +0 -0
- data/lib/smith/events/bus.rb +60 -0
- data/lib/smith/events/step_completed.rb +11 -0
- data/lib/smith/events/subscription.rb +24 -0
- data/lib/smith/events.rb +5 -0
- data/lib/smith/guardrails/runner.rb +44 -0
- data/lib/smith/guardrails/url_verifier.rb +7 -0
- data/lib/smith/guardrails.rb +35 -0
- data/lib/smith/models/inference.rb +199 -0
- data/lib/smith/models/normalizer.rb +186 -0
- data/lib/smith/models/profile.rb +39 -0
- data/lib/smith/models.rb +132 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
- data/lib/smith/persistence_adapters/cache_store.rb +79 -0
- data/lib/smith/persistence_adapters/memory.rb +105 -0
- data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
- data/lib/smith/persistence_adapters/redis_store.rb +136 -0
- data/lib/smith/persistence_adapters/retry.rb +42 -0
- data/lib/smith/persistence_adapters.rb +112 -0
- data/lib/smith/pricing.rb +65 -0
- data/lib/smith/providers/openai/responses.rb +315 -0
- data/lib/smith/providers/openai/routing.rb +67 -0
- data/lib/smith/providers/openai/tools_extensions.rb +106 -0
- data/lib/smith/railtie.rb +9 -0
- data/lib/smith/tasks/doctor.rake +38 -0
- data/lib/smith/tool/budget_enforcement.rb +33 -0
- data/lib/smith/tool/capability_builder.rb +18 -0
- data/lib/smith/tool/capture.rb +22 -0
- data/lib/smith/tool/compatibility.rb +72 -0
- data/lib/smith/tool/policy.rb +40 -0
- data/lib/smith/tool.rb +171 -0
- data/lib/smith/tools/think.rb +25 -0
- data/lib/smith/tools/url_fetcher.rb +16 -0
- data/lib/smith/tools/web_search.rb +17 -0
- data/lib/smith/tools.rb +5 -0
- data/lib/smith/trace/logger.rb +46 -0
- data/lib/smith/trace/memory.rb +53 -0
- data/lib/smith/trace/open_telemetry.rb +57 -0
- data/lib/smith/trace.rb +89 -0
- data/lib/smith/types.rb +16 -0
- data/lib/smith/version.rb +5 -0
- data/lib/smith/workflow/artifact_integration.rb +41 -0
- data/lib/smith/workflow/budget_integration.rb +105 -0
- data/lib/smith/workflow/claim.rb +118 -0
- data/lib/smith/workflow/data_volume_policy.rb +36 -0
- data/lib/smith/workflow/deadline_enforcement.rb +100 -0
- data/lib/smith/workflow/deterministic_execution.rb +53 -0
- data/lib/smith/workflow/deterministic_step.rb +57 -0
- data/lib/smith/workflow/dsl.rb +223 -0
- data/lib/smith/workflow/durability.rb +369 -0
- data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
- data/lib/smith/workflow/event_integration.rb +24 -0
- data/lib/smith/workflow/execution.rb +127 -0
- data/lib/smith/workflow/execution_frame.rb +166 -0
- data/lib/smith/workflow/guardrail_integration.rb +40 -0
- data/lib/smith/workflow/nested_execution.rb +69 -0
- data/lib/smith/workflow/orchestrator_worker.rb +145 -0
- data/lib/smith/workflow/parallel.rb +50 -0
- data/lib/smith/workflow/parallel_execution.rb +75 -0
- data/lib/smith/workflow/persistence.rb +358 -0
- data/lib/smith/workflow/pipeline.rb +117 -0
- data/lib/smith/workflow/router.rb +53 -0
- data/lib/smith/workflow/transition.rb +208 -0
- data/lib/smith/workflow.rb +555 -0
- data/lib/smith.rb +254 -0
- data/script/profile_tool_results.rb +94 -0
- data/sig/smith.rbs +4 -0
- metadata +258 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "persistence_adapters/cache_store"
|
|
5
|
+
require_relative "persistence_adapters/rails_cache"
|
|
6
|
+
require_relative "persistence_adapters/redis_store"
|
|
7
|
+
require_relative "persistence_adapters/active_record_store"
|
|
8
|
+
require_relative "persistence_adapters/memory"
|
|
9
|
+
require_relative "persistence_adapters/retry"
|
|
10
|
+
|
|
11
|
+
module Smith
|
|
12
|
+
module PersistenceAdapters
|
|
13
|
+
SolidCache = RailsCache
|
|
14
|
+
|
|
15
|
+
# REQUIRED_METHODS is the immutable adapter contract: any object
|
|
16
|
+
# responding to these is a valid Smith persistence adapter. This
|
|
17
|
+
# contract is preserved across the Phase B persistence hardening
|
|
18
|
+
# work; new optional capabilities (store_versioned, TTL kwarg) are
|
|
19
|
+
# additive and queried via respond_to?.
|
|
20
|
+
REQUIRED_METHODS = %i[store fetch delete].freeze
|
|
21
|
+
|
|
22
|
+
# OPTIONAL_METHODS: capabilities adapters MAY implement. Callers
|
|
23
|
+
# check support via `supports?(adapter, capability)` and fall back
|
|
24
|
+
# gracefully (e.g., Workflow#persist! warns once and uses plain
|
|
25
|
+
# `store` when `store_versioned` is missing).
|
|
26
|
+
OPTIONAL_METHODS = %i[store_versioned record_heartbeat last_heartbeat].freeze
|
|
27
|
+
|
|
28
|
+
def self.resolve(adapter, **options)
|
|
29
|
+
return nil if adapter.nil?
|
|
30
|
+
return validate!(adapter) if adapter_like?(adapter)
|
|
31
|
+
|
|
32
|
+
if adapter.is_a?(Class)
|
|
33
|
+
instance = options.empty? ? adapter.new : adapter.new(**options)
|
|
34
|
+
return validate!(instance)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
built_in = case adapter.to_sym
|
|
38
|
+
when :cache_store
|
|
39
|
+
CacheStore.new(**options)
|
|
40
|
+
when :rails_cache, :solid_cache
|
|
41
|
+
RailsCache.new(**options)
|
|
42
|
+
when :redis
|
|
43
|
+
RedisStore.new(**options)
|
|
44
|
+
when :active_record
|
|
45
|
+
ActiveRecordStore.new(**options)
|
|
46
|
+
when :memory
|
|
47
|
+
Memory.new
|
|
48
|
+
else
|
|
49
|
+
raise ArgumentError, "Unknown persistence adapter #{adapter.inspect}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
validate!(built_in)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.adapter_like?(adapter)
|
|
56
|
+
REQUIRED_METHODS.all? { |method_name| adapter.respond_to?(method_name) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.validate!(adapter)
|
|
60
|
+
return adapter if adapter_like?(adapter)
|
|
61
|
+
|
|
62
|
+
missing = REQUIRED_METHODS.reject { |method_name| adapter.respond_to?(method_name) }
|
|
63
|
+
raise ArgumentError, "Persistence adapter must implement #{missing.join(', ')}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Capability introspection used by Workflow#persist! to decide
|
|
67
|
+
# whether the adapter supports optimistic locking via store_versioned.
|
|
68
|
+
def self.supports?(adapter, capability)
|
|
69
|
+
adapter.respond_to?(capability)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Tracks which adapter CLASSES have already warned about missing
|
|
73
|
+
# store_versioned capability. One warning per adapter class per
|
|
74
|
+
# Smith boot (not per workflow instance, not per persist call).
|
|
75
|
+
@_warned_classes = Set.new
|
|
76
|
+
@_warned_monitor = Monitor.new
|
|
77
|
+
|
|
78
|
+
def self.warn_missing_versioning(adapter)
|
|
79
|
+
klass = adapter.class
|
|
80
|
+
@_warned_monitor.synchronize do
|
|
81
|
+
return if @_warned_classes.include?(klass)
|
|
82
|
+
|
|
83
|
+
@_warned_classes << klass
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Smith.config.logger&.warn(
|
|
87
|
+
"#{klass.name} does not implement store_versioned; " \
|
|
88
|
+
"optimistic locking is disabled for this adapter. " \
|
|
89
|
+
"Switch to RedisStore, ActiveRecordStore (with lock_version column), " \
|
|
90
|
+
"or the Memory adapter for race protection."
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@_warned_heartbeat_classes = Set.new
|
|
95
|
+
@_warned_heartbeat_monitor = Monitor.new
|
|
96
|
+
|
|
97
|
+
def self.warn_missing_heartbeat(adapter)
|
|
98
|
+
klass = adapter.class
|
|
99
|
+
@_warned_heartbeat_monitor.synchronize do
|
|
100
|
+
return if @_warned_heartbeat_classes.include?(klass)
|
|
101
|
+
|
|
102
|
+
@_warned_heartbeat_classes << klass
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
Smith.config.logger&.warn(
|
|
106
|
+
"#{klass.name} does not implement record_heartbeat/last_heartbeat; " \
|
|
107
|
+
"Smith::Workflow.stuck_for? falls back to payload['updated_at'] parsing. " \
|
|
108
|
+
"For accurate liveness probes, switch to RedisStore or Memory."
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Pricing
|
|
5
|
+
# Compute provider cost for a single agent call. Two pricing shapes
|
|
6
|
+
# are supported:
|
|
7
|
+
#
|
|
8
|
+
# Flat (existing): the catalog entry is a Hash with
|
|
9
|
+
# `:input_cost_per_token` / `:output_cost_per_token` keys. Used
|
|
10
|
+
# for models with a single rate across all input sizes
|
|
11
|
+
# (Gemini 2.5 Flash, Claude Opus 4.6/4.7).
|
|
12
|
+
#
|
|
13
|
+
# Tiered (new): the catalog entry has a `:tiers` array of bracket
|
|
14
|
+
# hashes, each with `:max_input_tokens` (nil = unbounded ceiling),
|
|
15
|
+
# `:input_cost_per_token`, `:output_cost_per_token`. Tiers are
|
|
16
|
+
# walked in order; the first whose `max_input_tokens` covers the
|
|
17
|
+
# call's input_tokens picks the rate. Used for models with
|
|
18
|
+
# long-context premium pricing (Gemini 2.5 Pro: $1.25/$10 below
|
|
19
|
+
# 200K input tokens, $2.50/$15 above).
|
|
20
|
+
def self.compute_cost(model:, input_tokens:, output_tokens:)
|
|
21
|
+
catalog = Smith.config.pricing
|
|
22
|
+
return nil unless catalog
|
|
23
|
+
|
|
24
|
+
entry = catalog[model.to_s]
|
|
25
|
+
return nil unless entry
|
|
26
|
+
|
|
27
|
+
rates = resolve_rates(entry, input_tokens)
|
|
28
|
+
return nil unless rates
|
|
29
|
+
|
|
30
|
+
input_rate, output_rate = rates
|
|
31
|
+
(input_tokens * input_rate) + (output_tokens * output_rate)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns [input_rate, output_rate] or nil if no applicable rate.
|
|
35
|
+
# Tiered shape is recognized by the presence of a :tiers key; flat
|
|
36
|
+
# shape is the legacy default.
|
|
37
|
+
def self.resolve_rates(entry, input_tokens)
|
|
38
|
+
tiers = entry[:tiers] || entry["tiers"]
|
|
39
|
+
if tiers.is_a?(Array) && !tiers.empty?
|
|
40
|
+
resolve_tiered(tiers, input_tokens)
|
|
41
|
+
else
|
|
42
|
+
flat = [entry[:input_cost_per_token], entry[:output_cost_per_token]]
|
|
43
|
+
return nil unless flat.all? { |r| r.is_a?(Numeric) }
|
|
44
|
+
|
|
45
|
+
flat
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_class_method :resolve_rates
|
|
49
|
+
|
|
50
|
+
def self.resolve_tiered(tiers, input_tokens)
|
|
51
|
+
tier = tiers.find do |t|
|
|
52
|
+
max = t[:max_input_tokens] || t["max_input_tokens"]
|
|
53
|
+
max.nil? || input_tokens <= max
|
|
54
|
+
end
|
|
55
|
+
return nil unless tier
|
|
56
|
+
|
|
57
|
+
input_rate = tier[:input_cost_per_token] || tier["input_cost_per_token"]
|
|
58
|
+
output_rate = tier[:output_cost_per_token] || tier["output_cost_per_token"]
|
|
59
|
+
return nil unless input_rate.is_a?(Numeric) && output_rate.is_a?(Numeric)
|
|
60
|
+
|
|
61
|
+
[input_rate, output_rate]
|
|
62
|
+
end
|
|
63
|
+
private_class_method :resolve_tiered
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# VENDORED FROM: crmne/ruby_llm PR #770
|
|
4
|
+
# Pinned SHA: a84517db65d3774c6b129dc88032fe32c8dbc722
|
|
5
|
+
# Source path: lib/ruby_llm/providers/openai/responses.rb (rev a84517d)
|
|
6
|
+
# License: MIT (matches RubyLLM upstream)
|
|
7
|
+
#
|
|
8
|
+
# Re-namespaced under `Smith::Providers::OpenAI::Responses`. The
|
|
9
|
+
# render/parse methods are vendored verbatim (only constant qualification
|
|
10
|
+
# changed: `Utils` → `::RubyLLM::Utils`, `Message` → `::RubyLLM::Message`,
|
|
11
|
+
# etc.). Smith adds:
|
|
12
|
+
# - `complete(provider, messages, ...)`: Smith-authored entry point
|
|
13
|
+
# that the routing prepend (Smith::Providers::OpenAI::Routing) calls
|
|
14
|
+
# once the normalizer flags a request for /v1/responses routing.
|
|
15
|
+
# Drives HTTP dispatch via the provider's Faraday connection.
|
|
16
|
+
# - Inline `format_role` + `resolve_effort` helpers (vendored from
|
|
17
|
+
# PR #770's chat.rb because Smith's Responses module is standalone,
|
|
18
|
+
# not mixed into the provider class as upstream does).
|
|
19
|
+
#
|
|
20
|
+
# RETIREMENT: this file goes away when PR #770 merges into RubyLLM
|
|
21
|
+
# (Smith bumps the ruby_llm dep + deletes this file + its routing
|
|
22
|
+
# branch). The retirement path is documented in UPSTREAM_PROPOSAL.md.
|
|
23
|
+
#
|
|
24
|
+
# SYNC PROTOCOL: do NOT edit "vendored verbatim" methods directly. To
|
|
25
|
+
# pull upstream changes before PR #770 merges, re-pin the SHA, re-fetch
|
|
26
|
+
# via `gh api repos/crmne/ruby_llm/contents/lib/ruby_llm/providers/
|
|
27
|
+
# openai/responses.rb?ref=<SHA>`, and replace the vendored block.
|
|
28
|
+
# Smith-authored additions are clearly marked "SMITH-AUTHORED".
|
|
29
|
+
|
|
30
|
+
require "json"
|
|
31
|
+
require "ruby_llm"
|
|
32
|
+
|
|
33
|
+
module Smith
|
|
34
|
+
module Providers
|
|
35
|
+
module OpenAI
|
|
36
|
+
# Responses API adapter consumed by Smith::Providers::OpenAI::Routing
|
|
37
|
+
# when Smith::Models::Normalizer flags a request for routing via
|
|
38
|
+
# OpenAI /v1/responses (typically: gpt-5 family + tools + thinking).
|
|
39
|
+
module Responses
|
|
40
|
+
# ---- Vendored verbatim from PR #770 responses.rb -----------------
|
|
41
|
+
|
|
42
|
+
RESPONSE_REASONING_TEXT_TYPES = %w[summary_text output_text].freeze
|
|
43
|
+
|
|
44
|
+
def self.responses_url
|
|
45
|
+
"responses"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# SMITH-AUTHORED entry point. Routing prepend calls this with
|
|
49
|
+
# the OpenAI provider instance + the same kwargs `complete`
|
|
50
|
+
# would receive. Renders the /v1/responses payload using the
|
|
51
|
+
# vendored helpers, POSTs via the provider's Faraday connection,
|
|
52
|
+
# parses the response back into a RubyLLM::Message.
|
|
53
|
+
#
|
|
54
|
+
# Streaming is intentionally NOT supported in this initial vendor
|
|
55
|
+
# because Smith's workflow execution path doesn't use it.
|
|
56
|
+
# Block-given calls raise NotImplementedError with a clear
|
|
57
|
+
# message so the host can either disable streaming or fall back
|
|
58
|
+
# to `openai_api_mode = :off`.
|
|
59
|
+
def self.complete(provider, messages, tools:, temperature:, model:, params: {}, headers: {},
|
|
60
|
+
schema: nil, thinking: nil, tool_prefs: nil, &block)
|
|
61
|
+
if block
|
|
62
|
+
raise NotImplementedError,
|
|
63
|
+
"Smith::Providers::OpenAI::Responses does not yet support streaming. " \
|
|
64
|
+
"Streaming over /v1/responses needs a separate stream_response port from PR #770. " \
|
|
65
|
+
"Workaround: pass no block (sync only), or set Smith.config.openai_api_mode = :off " \
|
|
66
|
+
"to route via chat-completions with graceful tool-dropping."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
payload = render_response_payload(
|
|
70
|
+
messages,
|
|
71
|
+
tools: tools,
|
|
72
|
+
temperature: temperature,
|
|
73
|
+
model: model,
|
|
74
|
+
stream: false,
|
|
75
|
+
schema: schema,
|
|
76
|
+
thinking: thinking,
|
|
77
|
+
tool_prefs: tool_prefs
|
|
78
|
+
)
|
|
79
|
+
payload = ::RubyLLM::Utils.deep_merge(payload, params) unless params.empty?
|
|
80
|
+
|
|
81
|
+
connection = provider.instance_variable_get(:@connection)
|
|
82
|
+
provider_headers = provider.send(:headers)
|
|
83
|
+
merged_headers = provider_headers.merge(headers)
|
|
84
|
+
|
|
85
|
+
http_response = connection.post(responses_url, payload) do |req|
|
|
86
|
+
merged_headers.each { |k, v| req.headers[k] = v }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
parse_response_response(http_response, provider: provider)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# rubocop:disable Metrics/ParameterLists
|
|
93
|
+
def self.render_response_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
|
|
94
|
+
thinking: nil, tool_prefs: nil, native_tools: nil)
|
|
95
|
+
tool_prefs ||= {}
|
|
96
|
+
payload = {
|
|
97
|
+
model: model.id,
|
|
98
|
+
input: format_response_input(messages),
|
|
99
|
+
stream: stream,
|
|
100
|
+
store: false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
payload[:temperature] = temperature unless temperature.nil?
|
|
104
|
+
apply_response_tools(payload, tools, native_tools, tool_prefs)
|
|
105
|
+
apply_response_schema(payload, schema) if schema
|
|
106
|
+
apply_response_thinking(payload, thinking)
|
|
107
|
+
payload
|
|
108
|
+
end
|
|
109
|
+
# rubocop:enable Metrics/ParameterLists
|
|
110
|
+
|
|
111
|
+
def self.format_response_input(messages)
|
|
112
|
+
messages.flat_map do |message|
|
|
113
|
+
if message.tool_call?
|
|
114
|
+
format_response_tool_calls(message.tool_calls)
|
|
115
|
+
elsif message.role == :tool
|
|
116
|
+
format_response_tool_result(message)
|
|
117
|
+
else
|
|
118
|
+
format_response_message(message)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# SMITH-AUTHORED kwarg addition: `provider:` is passed in so this
|
|
124
|
+
# standalone module can read `@config.openai_use_system_role` for
|
|
125
|
+
# `format_role`. Upstream method lives on the provider instance
|
|
126
|
+
# and reads `@config` directly; Smith's standalone module needs
|
|
127
|
+
# the indirection.
|
|
128
|
+
def self.parse_response_response(response, provider: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
129
|
+
data = response.body
|
|
130
|
+
return if data.empty?
|
|
131
|
+
|
|
132
|
+
raise ::RubyLLM::Error.new(response, data.dig("error", "message")) if data.dig("error", "message")
|
|
133
|
+
|
|
134
|
+
outputs = data["output"] || []
|
|
135
|
+
return if outputs.empty?
|
|
136
|
+
|
|
137
|
+
usage = data["usage"] || {}
|
|
138
|
+
|
|
139
|
+
::RubyLLM::Message.new(
|
|
140
|
+
role: :assistant,
|
|
141
|
+
content: response_output_text(data),
|
|
142
|
+
thinking: ::RubyLLM::Thinking.build(text: response_reasoning_text(outputs)),
|
|
143
|
+
tool_calls: ToolsExtensions.parse_response_tool_calls(outputs),
|
|
144
|
+
input_tokens: usage["input_tokens"],
|
|
145
|
+
output_tokens: usage["output_tokens"],
|
|
146
|
+
cached_tokens: usage.dig("input_tokens_details", "cached_tokens"),
|
|
147
|
+
cache_creation_tokens: usage.dig("input_tokens_details", "cache_write_tokens") || 0,
|
|
148
|
+
thinking_tokens: usage.dig("output_tokens_details", "reasoning_tokens"),
|
|
149
|
+
model_id: data["model"],
|
|
150
|
+
raw: response
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.format_response_message(message, provider: nil)
|
|
155
|
+
{
|
|
156
|
+
type: "message",
|
|
157
|
+
role: format_role(message.role, provider: provider),
|
|
158
|
+
content: format_response_content(message.content)
|
|
159
|
+
}.compact
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.format_response_tool_calls(tool_calls)
|
|
163
|
+
tool_calls.map do |_, tool_call|
|
|
164
|
+
{
|
|
165
|
+
type: "function_call",
|
|
166
|
+
call_id: tool_call.id,
|
|
167
|
+
name: tool_call.name,
|
|
168
|
+
arguments: JSON.generate(tool_call.arguments || {})
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.format_response_tool_result(message)
|
|
174
|
+
{
|
|
175
|
+
type: "function_call_output",
|
|
176
|
+
call_id: message.tool_call_id,
|
|
177
|
+
output: response_tool_output(message.content)
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.apply_response_tools(payload, tools, native_tools, tool_prefs)
|
|
182
|
+
response_tools = tools.map { |_, tool| ToolsExtensions.response_tool_for(tool) }
|
|
183
|
+
response_tools.concat(::RubyLLM::Utils.to_safe_array(native_tools))
|
|
184
|
+
payload[:tools] = response_tools if response_tools.any?
|
|
185
|
+
unless tool_prefs[:choice].nil?
|
|
186
|
+
payload[:tool_choice] = ToolsExtensions.build_response_tool_choice(tool_prefs[:choice])
|
|
187
|
+
end
|
|
188
|
+
payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.apply_response_schema(payload, schema)
|
|
192
|
+
payload[:text] = {
|
|
193
|
+
format: {
|
|
194
|
+
type: "json_schema",
|
|
195
|
+
name: schema[:name],
|
|
196
|
+
schema: schema[:schema],
|
|
197
|
+
strict: schema[:strict]
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def self.apply_response_thinking(payload, thinking)
|
|
203
|
+
effort = resolve_effort(thinking)
|
|
204
|
+
payload[:reasoning] = { effort: effort } if effort
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.format_response_content(content)
|
|
208
|
+
return content.value if content.is_a?(::RubyLLM::Content::Raw)
|
|
209
|
+
return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
|
|
210
|
+
return content unless content.is_a?(::RubyLLM::Content)
|
|
211
|
+
|
|
212
|
+
parts = []
|
|
213
|
+
parts << format_response_text(content.text) if content.text
|
|
214
|
+
|
|
215
|
+
content.attachments.each do |attachment|
|
|
216
|
+
parts << format_response_attachment(attachment)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
parts
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def self.format_response_attachment(attachment)
|
|
223
|
+
case attachment.type
|
|
224
|
+
when :image
|
|
225
|
+
{
|
|
226
|
+
type: "input_image",
|
|
227
|
+
image_url: attachment.url? ? attachment.source.to_s : attachment.for_llm
|
|
228
|
+
}
|
|
229
|
+
when :pdf
|
|
230
|
+
{
|
|
231
|
+
type: "input_file",
|
|
232
|
+
filename: attachment.filename,
|
|
233
|
+
file_data: attachment.for_llm
|
|
234
|
+
}
|
|
235
|
+
when :text
|
|
236
|
+
format_response_text(attachment.for_llm)
|
|
237
|
+
when :audio
|
|
238
|
+
raise ::RubyLLM::UnsupportedAttachmentError, "OpenAI Responses API does not support audio inputs yet"
|
|
239
|
+
else
|
|
240
|
+
raise ::RubyLLM::UnsupportedAttachmentError, attachment.type
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def self.format_response_text(text)
|
|
245
|
+
{
|
|
246
|
+
type: "input_text",
|
|
247
|
+
text: text
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def self.response_tool_output(content)
|
|
252
|
+
return JSON.generate(content.value) if content.is_a?(::RubyLLM::Content::Raw)
|
|
253
|
+
return content.text.to_s if content.is_a?(::RubyLLM::Content) && content.text
|
|
254
|
+
return JSON.generate(content.to_h) if content.is_a?(::RubyLLM::Content)
|
|
255
|
+
return JSON.generate(content) if content.is_a?(Hash) || content.is_a?(Array)
|
|
256
|
+
|
|
257
|
+
content.to_s
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def self.response_output_text(data)
|
|
261
|
+
output_text = data["output_text"]
|
|
262
|
+
return output_text if output_text.is_a?(String) && !output_text.empty?
|
|
263
|
+
|
|
264
|
+
text = response_output_text_parts(data["output"]).join
|
|
265
|
+
text.empty? ? nil : text
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def self.response_output_text_parts(outputs)
|
|
269
|
+
::RubyLLM::Utils.to_safe_array(outputs).select { |output| output["type"] == "message" }.flat_map do |output|
|
|
270
|
+
::RubyLLM::Utils.to_safe_array(output["content"]).filter_map do |content|
|
|
271
|
+
content["text"] if content["type"] == "output_text" && content["text"].is_a?(String)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def self.response_reasoning_text(outputs)
|
|
277
|
+
text = outputs.select { |output| output["type"] == "reasoning" }.flat_map do |output|
|
|
278
|
+
::RubyLLM::Utils.to_safe_array(output["summary"] || output["content"]).filter_map do |content|
|
|
279
|
+
if RESPONSE_REASONING_TEXT_TYPES.include?(content["type"]) && content["text"].is_a?(String)
|
|
280
|
+
content["text"]
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end.join
|
|
284
|
+
|
|
285
|
+
text.empty? ? nil : text
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# ---- SMITH-AUTHORED helpers (inlined from PR #770 chat.rb) -------
|
|
289
|
+
#
|
|
290
|
+
# Upstream PR #770 keeps these on the chat module (which is mixed
|
|
291
|
+
# into the provider class so they're available as instance methods
|
|
292
|
+
# with access to @config). Smith's vendored Responses module is
|
|
293
|
+
# standalone (it can't read @config), so these helpers are
|
|
294
|
+
# inlined as class methods with the provider passed in where
|
|
295
|
+
# @config access is needed.
|
|
296
|
+
|
|
297
|
+
def self.format_role(role, provider: nil)
|
|
298
|
+
case role
|
|
299
|
+
when :system
|
|
300
|
+
config = provider&.instance_variable_get(:@config)
|
|
301
|
+
(config && config.respond_to?(:openai_use_system_role) && config.openai_use_system_role) ? "system" : "developer"
|
|
302
|
+
else
|
|
303
|
+
role.to_s
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def self.resolve_effort(thinking)
|
|
308
|
+
return nil unless thinking
|
|
309
|
+
|
|
310
|
+
thinking.respond_to?(:effort) ? thinking.effort : thinking
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Providers
|
|
7
|
+
module OpenAI
|
|
8
|
+
# Prepended onto RubyLLM::Providers::OpenAI. Intercepts the chat
|
|
9
|
+
# `complete` call and routes to /v1/responses when the rendered
|
|
10
|
+
# payload's `openai_api_mode` hint (set by the normalizer via
|
|
11
|
+
# `chat.with_params(openai_api_mode: :responses)`) requests it.
|
|
12
|
+
#
|
|
13
|
+
# Does NOT rename `RubyLLM::Providers::OpenAI::Chat#completion_url`
|
|
14
|
+
# (PR #770 does; Smith diverges to keep the surface narrower).
|
|
15
|
+
# The instance_of? check prevents routing on OpenAI-compatible
|
|
16
|
+
# subclasses (OpenRouter, Azure, Bedrock).
|
|
17
|
+
#
|
|
18
|
+
# The full /v1/responses payload assembly lives in
|
|
19
|
+
# Smith::Providers::OpenAI::Responses (vendored from
|
|
20
|
+
# crmne/ruby_llm PR #770 at SHA a84517db65d3774c6b129dc88032fe32c8dbc722).
|
|
21
|
+
# When the PR merges upstream, Smith bumps the ruby_llm dep and
|
|
22
|
+
# deletes the vendored files. The defined? guard in
|
|
23
|
+
# `route_via_responses` keeps the routing safe even if a host pins
|
|
24
|
+
# an older Smith without the vendored adapter, raising a clear
|
|
25
|
+
# NotImplementedError rather than silently falling through to
|
|
26
|
+
# chat-completions (which would still fail with the original
|
|
27
|
+
# tools+reasoning combo error).
|
|
28
|
+
module Routing
|
|
29
|
+
def complete(messages, tools:, temperature:, model:, params: {}, headers: {},
|
|
30
|
+
schema: nil, thinking: nil, tool_prefs: nil, &)
|
|
31
|
+
mode = params[:openai_api_mode] || params["openai_api_mode"]
|
|
32
|
+
if mode.to_s == "responses" && instance_of?(::RubyLLM::Providers::OpenAI)
|
|
33
|
+
route_via_responses(
|
|
34
|
+
messages,
|
|
35
|
+
tools: tools, temperature: temperature, model: model,
|
|
36
|
+
params: params.except(:openai_api_mode, "openai_api_mode"),
|
|
37
|
+
headers: headers, schema: schema, thinking: thinking,
|
|
38
|
+
tool_prefs: tool_prefs, &
|
|
39
|
+
)
|
|
40
|
+
else
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def route_via_responses(messages, **, &)
|
|
48
|
+
if defined?(Smith::Providers::OpenAI::Responses)
|
|
49
|
+
Smith::Providers::OpenAI::Responses.complete(self, messages, **, &)
|
|
50
|
+
else
|
|
51
|
+
raise NotImplementedError,
|
|
52
|
+
"Smith::Providers::OpenAI::Responses (the /v1/responses adapter) " \
|
|
53
|
+
"is not yet vendored. PR #770 on crmne/ruby_llm tracks the upstream " \
|
|
54
|
+
"implementation. Until it lands, set Smith.config.openai_api_mode = :off " \
|
|
55
|
+
"to fall back to graceful tool-dropping when (gpt-5 + tools + thinking) " \
|
|
56
|
+
"is detected."
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Install once at gem-require. Idempotent.
|
|
65
|
+
unless RubyLLM::Providers::OpenAI.ancestors.include?(Smith::Providers::OpenAI::Routing)
|
|
66
|
+
RubyLLM::Providers::OpenAI.prepend(Smith::Providers::OpenAI::Routing)
|
|
67
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# VENDORED FROM: crmne/ruby_llm PR #770
|
|
4
|
+
# Pinned SHA: a84517db65d3774c6b129dc88032fe32c8dbc722
|
|
5
|
+
# Source path: lib/ruby_llm/providers/openai/tools.rb (rev a84517d)
|
|
6
|
+
# License: MIT (matches RubyLLM upstream)
|
|
7
|
+
#
|
|
8
|
+
# Re-namespaced under `Smith::Providers::OpenAI::ToolsExtensions` (the
|
|
9
|
+
# upstream is `RubyLLM::Providers::OpenAI::Tools`, which Smith cannot
|
|
10
|
+
# vendor under the same name without colliding with RubyLLM's existing
|
|
11
|
+
# Tools module). Functionally a verbatim copy of the methods needed by
|
|
12
|
+
# Smith::Providers::OpenAI::Responses; constants and helpers used only
|
|
13
|
+
# by the chat-completions path were intentionally omitted.
|
|
14
|
+
#
|
|
15
|
+
# RETIREMENT: this file goes away when PR #770 merges into RubyLLM
|
|
16
|
+
# (Smith bumps the ruby_llm dep + deletes this file + the routing
|
|
17
|
+
# branch that references it). Tracking: UPSTREAM_PROPOSAL.md retirement
|
|
18
|
+
# checklist.
|
|
19
|
+
#
|
|
20
|
+
# SYNC PROTOCOL: do NOT modify methods marked "vendored verbatim". When
|
|
21
|
+
# PR #770 lands changes upstream before merge, re-pin the SHA at the
|
|
22
|
+
# top of this file, re-fetch via `gh api repos/crmne/ruby_llm/contents/
|
|
23
|
+
# lib/ruby_llm/providers/openai/tools.rb?ref=<SHA>`, and replace the
|
|
24
|
+
# vendored block. Smith-authored helpers (none currently in this file)
|
|
25
|
+
# would be marked with "SMITH-AUTHORED" comments.
|
|
26
|
+
|
|
27
|
+
require "ruby_llm"
|
|
28
|
+
|
|
29
|
+
module Smith
|
|
30
|
+
module Providers
|
|
31
|
+
module OpenAI
|
|
32
|
+
# Tool format helpers consumed by Smith::Providers::OpenAI::Responses.
|
|
33
|
+
# Vendored from PR #770; namespace-only changes.
|
|
34
|
+
module ToolsExtensions
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
EMPTY_PARAMETERS_SCHEMA = {
|
|
38
|
+
"type" => "object",
|
|
39
|
+
"properties" => {},
|
|
40
|
+
"required" => [],
|
|
41
|
+
"additionalProperties" => false,
|
|
42
|
+
"strict" => true
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
def parameters_schema_for(tool)
|
|
46
|
+
tool.params_schema ||
|
|
47
|
+
schema_from_parameters(tool.parameters)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def schema_from_parameters(parameters)
|
|
51
|
+
schema_definition = ::RubyLLM::Tool::SchemaDefinition.from_parameters(parameters)
|
|
52
|
+
schema_definition&.json_schema || EMPTY_PARAMETERS_SCHEMA
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def response_tool_for(tool)
|
|
56
|
+
definition = {
|
|
57
|
+
type: "function",
|
|
58
|
+
name: tool.name,
|
|
59
|
+
description: tool.description,
|
|
60
|
+
parameters: parameters_schema_for(tool)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return definition if tool.provider_params.empty?
|
|
64
|
+
|
|
65
|
+
::RubyLLM::Utils.deep_merge(definition, tool.provider_params)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_response_tool_calls(outputs)
|
|
69
|
+
function_calls = ::RubyLLM::Utils.to_safe_array(outputs).select { |output| output["type"] == "function_call" }
|
|
70
|
+
return nil if function_calls.empty?
|
|
71
|
+
|
|
72
|
+
function_calls.to_h do |output|
|
|
73
|
+
id = output["call_id"] || output["id"]
|
|
74
|
+
[
|
|
75
|
+
id,
|
|
76
|
+
::RubyLLM::ToolCall.new(
|
|
77
|
+
id: id,
|
|
78
|
+
name: output["name"],
|
|
79
|
+
arguments: parse_response_tool_call_arguments(output)
|
|
80
|
+
)
|
|
81
|
+
]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_response_tool_call_arguments(output)
|
|
86
|
+
arguments = output["arguments"]
|
|
87
|
+
return {} if arguments.nil? || arguments.empty?
|
|
88
|
+
|
|
89
|
+
JSON.parse(arguments)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_response_tool_choice(tool_choice)
|
|
93
|
+
case tool_choice
|
|
94
|
+
when :auto, :none, :required
|
|
95
|
+
tool_choice
|
|
96
|
+
else
|
|
97
|
+
{
|
|
98
|
+
type: "function",
|
|
99
|
+
name: tool_choice
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|