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.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +139 -0
  3. data/CODE_OF_CONDUCT.md +128 -0
  4. data/LICENSE +21 -0
  5. data/README.md +226 -0
  6. data/Rakefile +14 -0
  7. data/UPSTREAM_PROPOSAL.md +141 -0
  8. data/docs/CONFIGURATION.md +123 -0
  9. data/docs/PATTERNS.md +492 -0
  10. data/docs/PERSISTENCE.md +169 -0
  11. data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
  12. data/docs/workflow_claim.md +58 -0
  13. data/exe/smith +7 -0
  14. data/lib/generators/smith/install/install_generator.rb +22 -0
  15. data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
  16. data/lib/smith/agent/lifecycle.rb +264 -0
  17. data/lib/smith/agent/registry.rb +128 -0
  18. data/lib/smith/agent.rb +259 -0
  19. data/lib/smith/artifacts/file.rb +59 -0
  20. data/lib/smith/artifacts/memory.rb +75 -0
  21. data/lib/smith/artifacts/scoped_store.rb +29 -0
  22. data/lib/smith/artifacts.rb +5 -0
  23. data/lib/smith/budget/ledger.rb +42 -0
  24. data/lib/smith/budget.rb +5 -0
  25. data/lib/smith/cli.rb +82 -0
  26. data/lib/smith/context/observation_masking.rb +19 -0
  27. data/lib/smith/context/session.rb +42 -0
  28. data/lib/smith/context/state_injection.rb +24 -0
  29. data/lib/smith/context.rb +61 -0
  30. data/lib/smith/doctor/check.rb +12 -0
  31. data/lib/smith/doctor/checks/baseline.rb +84 -0
  32. data/lib/smith/doctor/checks/configuration.rb +56 -0
  33. data/lib/smith/doctor/checks/durability.rb +103 -0
  34. data/lib/smith/doctor/checks/live.rb +55 -0
  35. data/lib/smith/doctor/checks/models_registry.rb +66 -0
  36. data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
  37. data/lib/smith/doctor/checks/persistence.rb +99 -0
  38. data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
  39. data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
  40. data/lib/smith/doctor/checks/rails.rb +39 -0
  41. data/lib/smith/doctor/checks/serialization.rb +78 -0
  42. data/lib/smith/doctor/installer.rb +103 -0
  43. data/lib/smith/doctor/printer.rb +62 -0
  44. data/lib/smith/doctor/report.rb +39 -0
  45. data/lib/smith/doctor.rb +53 -0
  46. data/lib/smith/errors.rb +191 -0
  47. data/lib/smith/event.rb +11 -0
  48. data/lib/smith/events/.keep +0 -0
  49. data/lib/smith/events/bus.rb +60 -0
  50. data/lib/smith/events/step_completed.rb +11 -0
  51. data/lib/smith/events/subscription.rb +24 -0
  52. data/lib/smith/events.rb +5 -0
  53. data/lib/smith/guardrails/runner.rb +44 -0
  54. data/lib/smith/guardrails/url_verifier.rb +7 -0
  55. data/lib/smith/guardrails.rb +35 -0
  56. data/lib/smith/models/inference.rb +199 -0
  57. data/lib/smith/models/normalizer.rb +186 -0
  58. data/lib/smith/models/profile.rb +39 -0
  59. data/lib/smith/models.rb +132 -0
  60. data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
  61. data/lib/smith/persistence_adapters/cache_store.rb +79 -0
  62. data/lib/smith/persistence_adapters/memory.rb +105 -0
  63. data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
  64. data/lib/smith/persistence_adapters/redis_store.rb +136 -0
  65. data/lib/smith/persistence_adapters/retry.rb +42 -0
  66. data/lib/smith/persistence_adapters.rb +112 -0
  67. data/lib/smith/pricing.rb +65 -0
  68. data/lib/smith/providers/openai/responses.rb +315 -0
  69. data/lib/smith/providers/openai/routing.rb +67 -0
  70. data/lib/smith/providers/openai/tools_extensions.rb +106 -0
  71. data/lib/smith/railtie.rb +9 -0
  72. data/lib/smith/tasks/doctor.rake +38 -0
  73. data/lib/smith/tool/budget_enforcement.rb +33 -0
  74. data/lib/smith/tool/capability_builder.rb +18 -0
  75. data/lib/smith/tool/capture.rb +22 -0
  76. data/lib/smith/tool/compatibility.rb +72 -0
  77. data/lib/smith/tool/policy.rb +40 -0
  78. data/lib/smith/tool.rb +171 -0
  79. data/lib/smith/tools/think.rb +25 -0
  80. data/lib/smith/tools/url_fetcher.rb +16 -0
  81. data/lib/smith/tools/web_search.rb +17 -0
  82. data/lib/smith/tools.rb +5 -0
  83. data/lib/smith/trace/logger.rb +46 -0
  84. data/lib/smith/trace/memory.rb +53 -0
  85. data/lib/smith/trace/open_telemetry.rb +57 -0
  86. data/lib/smith/trace.rb +89 -0
  87. data/lib/smith/types.rb +16 -0
  88. data/lib/smith/version.rb +5 -0
  89. data/lib/smith/workflow/artifact_integration.rb +41 -0
  90. data/lib/smith/workflow/budget_integration.rb +105 -0
  91. data/lib/smith/workflow/claim.rb +118 -0
  92. data/lib/smith/workflow/data_volume_policy.rb +36 -0
  93. data/lib/smith/workflow/deadline_enforcement.rb +100 -0
  94. data/lib/smith/workflow/deterministic_execution.rb +53 -0
  95. data/lib/smith/workflow/deterministic_step.rb +57 -0
  96. data/lib/smith/workflow/dsl.rb +223 -0
  97. data/lib/smith/workflow/durability.rb +369 -0
  98. data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
  99. data/lib/smith/workflow/event_integration.rb +24 -0
  100. data/lib/smith/workflow/execution.rb +127 -0
  101. data/lib/smith/workflow/execution_frame.rb +166 -0
  102. data/lib/smith/workflow/guardrail_integration.rb +40 -0
  103. data/lib/smith/workflow/nested_execution.rb +69 -0
  104. data/lib/smith/workflow/orchestrator_worker.rb +145 -0
  105. data/lib/smith/workflow/parallel.rb +50 -0
  106. data/lib/smith/workflow/parallel_execution.rb +75 -0
  107. data/lib/smith/workflow/persistence.rb +358 -0
  108. data/lib/smith/workflow/pipeline.rb +117 -0
  109. data/lib/smith/workflow/router.rb +53 -0
  110. data/lib/smith/workflow/transition.rb +208 -0
  111. data/lib/smith/workflow.rb +555 -0
  112. data/lib/smith.rb +254 -0
  113. data/script/profile_tool_results.rb +94 -0
  114. data/sig/smith.rbs +4 -0
  115. 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Railtie < ::Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path("tasks/doctor.rake", __dir__)
7
+ end
8
+ end
9
+ end