riffer 0.40.0 → 0.42.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/{AGENTS.md → .claude/CLAUDE.md} +0 -8
  3. data/.claude/rules/comments.md +13 -0
  4. data/.claude/rules/rbs-inline.md +93 -0
  5. data/.release-please-manifest.json +1 -1
  6. data/CHANGELOG.md +22 -0
  7. data/README.md +20 -24
  8. data/docs/AGENTS.md +24 -14
  9. data/docs/AGENT_LIFECYCLE.md +2 -2
  10. data/docs/CONFIGURATION.md +105 -21
  11. data/docs/SERIALIZATION.md +4 -3
  12. data/docs/TOOLS.md +18 -1
  13. data/docs/providers/AMAZON_BEDROCK.md +14 -8
  14. data/docs/providers/ANTHROPIC.md +9 -7
  15. data/docs/providers/AZURE_OPENAI.md +12 -12
  16. data/docs/providers/CUSTOM_PROVIDERS.md +32 -16
  17. data/docs/providers/GEMINI.md +28 -5
  18. data/docs/providers/OPENAI.md +21 -5
  19. data/docs/providers/OPENROUTER.md +11 -6
  20. data/docs/providers/PROVIDERS.md +16 -1
  21. data/lib/riffer/agent/config.rb +0 -6
  22. data/lib/riffer/agent/run.rb +2 -4
  23. data/lib/riffer/agent/serializer.rb +0 -2
  24. data/lib/riffer/agent.rb +4 -26
  25. data/lib/riffer/config.rb +6 -6
  26. data/lib/riffer/evals/judge.rb +3 -5
  27. data/lib/riffer/guardrail.rb +1 -1
  28. data/lib/riffer/helpers/identifier.rb +41 -0
  29. data/lib/riffer/providers/amazon_bedrock.rb +31 -19
  30. data/lib/riffer/providers/anthropic.rb +23 -8
  31. data/lib/riffer/providers/azure_open_ai.rb +19 -11
  32. data/lib/riffer/providers/base.rb +32 -1
  33. data/lib/riffer/providers/gemini/client.rb +120 -0
  34. data/lib/riffer/providers/gemini.rb +13 -62
  35. data/lib/riffer/providers/mock.rb +5 -4
  36. data/lib/riffer/providers/open_ai.rb +23 -8
  37. data/lib/riffer/providers/open_router.rb +23 -9
  38. data/lib/riffer/registrable.rb +81 -0
  39. data/lib/riffer/tool.rb +1 -0
  40. data/lib/riffer/tools/toolable.rb +2 -3
  41. data/lib/riffer/version.rb +1 -1
  42. data/lib/riffer.rb +3 -0
  43. data/sig/_private/riffer/providers/amazon_bedrock.rbs +4 -2
  44. data/sig/_private/riffer/providers/anthropic.rbs +4 -2
  45. data/sig/_private/riffer/providers/gemini.rbs +7 -0
  46. data/sig/_private/riffer/providers/open_ai.rbs +4 -2
  47. data/sig/_private/riffer/providers/open_router.rbs +4 -2
  48. data/sig/generated/riffer/agent/config.rbs +1 -5
  49. data/sig/generated/riffer/agent.rbs +2 -18
  50. data/sig/generated/riffer/config.rbs +25 -15
  51. data/sig/generated/riffer/evals/judge.rbs +2 -4
  52. data/sig/generated/riffer/helpers/identifier.rbs +19 -0
  53. data/sig/generated/riffer/providers/amazon_bedrock.rbs +13 -2
  54. data/sig/generated/riffer/providers/anthropic.rbs +13 -2
  55. data/sig/generated/riffer/providers/azure_open_ai.rbs +14 -4
  56. data/sig/generated/riffer/providers/base.rbs +22 -0
  57. data/sig/generated/riffer/providers/gemini/client.rbs +65 -0
  58. data/sig/generated/riffer/providers/gemini.rbs +7 -23
  59. data/sig/generated/riffer/providers/mock.rbs +4 -3
  60. data/sig/generated/riffer/providers/open_ai.rbs +13 -2
  61. data/sig/generated/riffer/providers/open_router.rbs +16 -3
  62. data/sig/generated/riffer/registrable.rbs +51 -0
  63. data/sig/generated/riffer/tool.rbs +2 -0
  64. data/sig/generated/riffer/tools/toolable.rbs +3 -1
  65. data/sig/generated/riffer.rbs +4 -0
  66. data/sig/manual/riffer/agent.rbs +7 -0
  67. data/sig/manual/riffer/helpers/identifier.rbs +5 -0
  68. data/sig/manual/riffer/tool.rbs +7 -0
  69. metadata +14 -11
  70. data/.agents/architecture.md +0 -265
  71. data/.agents/code-style.md +0 -110
  72. data/.agents/providers.md +0 -45
  73. data/.agents/rbs-inline.md +0 -169
  74. data/.agents/testing.md +0 -60
  75. data/CLAUDE.md +0 -1
  76. data/lib/riffer/helpers/class_name_converter.rb +0 -22
  77. data/sig/generated/riffer/helpers/class_name_converter.rbs +0 -12
  78. data/sig/manual/riffer/helpers/class_name_converter.rbs +0 -5
@@ -30,17 +30,28 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
30
30
  end
31
31
 
32
32
  #--
33
- #: (?api_key: String?, **untyped) -> void
34
- def initialize(api_key: nil, **)
35
- super()
33
+ #: () -> void
34
+ def initialize
35
+ super
36
36
  depends_on "anthropic"
37
+ end
37
38
 
38
- api_key ||= Riffer.config.anthropic.api_key
39
+ private
39
40
 
40
- @client = ::Anthropic::Client.new(api_key: api_key, **)
41
+ #--
42
+ #: () -> untyped
43
+ def global_client
44
+ Riffer.config.anthropic.client
41
45
  end
42
46
 
43
- private
47
+ # Compacted for the same reason as the other providers: never hand an SDK an
48
+ # explicit nil credential, so its own +ANTHROPIC_API_KEY+ resolution stays
49
+ # reachable regardless of how that SDK distinguishes nil from absent.
50
+ #--
51
+ #: () -> untyped
52
+ def build_client
53
+ ::Anthropic::Client.new(**{ api_key: Riffer.config.anthropic.api_key }.compact)
54
+ end
44
55
 
45
56
  #--
46
57
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
@@ -97,7 +108,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
97
108
  #--
98
109
  #: (Hash[Symbol, untyped]) -> untyped
99
110
  def execute_generate(params)
100
- @client.messages.create(**params)
111
+ client.messages.create(**params)
101
112
  end
102
113
 
103
114
  #--
@@ -193,7 +204,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
193
204
 
194
205
  # Workaround for anthropics/anthropic-sdk-ruby#182: force identity
195
206
  # encoding so Net::HTTP/Zlib doesn't buffer SSE chunks until EOF.
196
- stream = @client.messages.stream(
207
+ stream = client.messages.stream(
197
208
  **params,
198
209
  request_options: { extra_headers: { "accept-encoding" => "identity" } },
199
210
  )
@@ -281,6 +292,10 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
281
292
  #--
282
293
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
283
294
  def handle_input_json_event(event, state:, yielder:)
295
+ # server_tool_use (web_search) input streams through the raw-event path
296
+ # (handle_raw_content_block_delta) and must not surface as tool-call deltas.
297
+ return if state[:web_search_json]
298
+
284
299
  state[:tool_call] = { id: nil, name: nil, arguments: +"" } if state[:tool_call].nil?
285
300
  state[:tool_call][:arguments] << event.partial_json
286
301
  yielder << Riffer::StreamEvents::ToolCallDelta.new(
@@ -2,8 +2,8 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  # Azure OpenAI provider for GPT models hosted on Azure. Requires the +openai+
5
- # gem. Credentials resolve from kwargs, then config, then
6
- # +AZURE_OPENAI_API_KEY+ / +AZURE_OPENAI_ENDPOINT+.
5
+ # gem. Credentials resolve from config, then +AZURE_OPENAI_API_KEY+ /
6
+ # +AZURE_OPENAI_ENDPOINT+.
7
7
  class Riffer::Providers::AzureOpenAI < Riffer::Providers::OpenAI
8
8
  # The GenAI semconv well-known provider name.
9
9
  #--
@@ -12,15 +12,23 @@ class Riffer::Providers::AzureOpenAI < Riffer::Providers::OpenAI
12
12
  "azure.ai.openai"
13
13
  end
14
14
 
15
+ private
16
+
17
+ #--
18
+ #: () -> untyped
19
+ def global_client
20
+ Riffer.config.azure_openai.client
21
+ end
22
+
23
+ # Deliberately not compacted: this borrows the OpenAI SDK to talk to Azure, so
24
+ # omitting an unset argument would let the SDK fall back to +OPENAI_API_KEY+
25
+ # and +OPENAI_BASE_URL+ — sending Azure traffic, and an OpenAI credential, to
26
+ # whatever those name. Passing nil raises in the SDK instead.
15
27
  #--
16
- #: (**untyped) -> void
17
- def initialize(**options)
18
- api_key = options.fetch(:api_key) do
19
- Riffer.config.azure_openai.api_key || ENV.fetch("AZURE_OPENAI_API_KEY", nil)
20
- end
21
- base_url = options.fetch(:base_url) do
22
- Riffer.config.azure_openai.endpoint || ENV.fetch("AZURE_OPENAI_ENDPOINT", nil)
23
- end
24
- super(api_key: api_key, base_url: base_url, **options.except(:api_key, :base_url))
28
+ #: () -> untyped
29
+ def build_client
30
+ api_key = Riffer.config.azure_openai.api_key || ENV.fetch("AZURE_OPENAI_API_KEY", nil)
31
+ base_url = Riffer.config.azure_openai.endpoint || ENV.fetch("AZURE_OPENAI_ENDPOINT", nil)
32
+ ::OpenAI::Client.new(api_key: api_key, base_url: base_url)
25
33
  end
26
34
  end
@@ -10,6 +10,8 @@ require "json"
10
10
  class Riffer::Providers::Base
11
11
  # @rbs @current_tools: Array[singleton(Riffer::Tool)]
12
12
  # @rbs @current_model: String?
13
+ # @rbs @client: untyped
14
+ # @rbs self.@semconv_provider_name: String?
13
15
 
14
16
  WIRE_SEPARATOR = "__" #: String
15
17
 
@@ -29,9 +31,11 @@ class Riffer::Providers::Base
29
31
  #: () -> String
30
32
  def self.semconv_provider_name
31
33
  class_name = name
34
+ # Anonymous classes stay uncached: a class assigned to a constant
35
+ # later must pick up its real name, not a frozen "unknown".
32
36
  return "unknown" unless class_name
33
37
 
34
- Riffer::Helpers::ClassNameConverter.convert(class_name.split("::").last.to_s)
38
+ @semconv_provider_name ||= Riffer::Helpers::Identifier.derive(class_name.split("::").last)
35
39
  end
36
40
 
37
41
  # Generates text using the provider.
@@ -105,6 +109,33 @@ class Riffer::Providers::Base
105
109
  Riffer::Helpers::Dependencies.depends_on(gem_name)
106
110
  end
107
111
 
112
+ # Returns the client for the current LLM call. A configured client wins,
113
+ # resolved on every call so a Proc can vary the client by process or
114
+ # credential lifetime; otherwise the provider builds one from the configured
115
+ # credentials, memoized for the life of the provider.
116
+ #--
117
+ #: () -> untyped
118
+ def client
119
+ configured = global_client
120
+ return Riffer::Helpers::CallOrValue.resolve(configured) if configured
121
+
122
+ @client ||= build_client
123
+ end
124
+
125
+ # Returns the consumer-configured client for this provider; nil when none is
126
+ # configured, and for providers that take no configuration at all.
127
+ #--
128
+ #: () -> untyped
129
+ def global_client
130
+ nil
131
+ end
132
+
133
+ #--
134
+ #: () -> untyped
135
+ def build_client
136
+ raise NotImplementedError, "Subclasses must implement #build_client"
137
+ end
138
+
108
139
  #--
109
140
  #: (String) -> String
110
141
  def encode_tool_name(name)
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ # HTTP transport for the Gemini REST API. Riffer builds one from the
9
+ # configured +api_key+ by default; construct your own to tune the HTTP knobs
10
+ # and assign it to <tt>Riffer.config.gemini.client</tt>. Any object
11
+ # implementing +post+ and +post_stream+ with these contracts works there —
12
+ # the class is a default implementation, not a required base.
13
+ #
14
+ # Riffer.configure do |config|
15
+ # config.gemini.client = Riffer::Providers::Gemini::Client.new(
16
+ # api_key: ENV["GEMINI_API_KEY"],
17
+ # read_timeout: 120
18
+ # )
19
+ # end
20
+ class Riffer::Providers::Gemini::Client
21
+ # @rbs @api_key: String?
22
+ # @rbs @base_url: String
23
+ # @rbs @open_timeout: Integer
24
+ # @rbs @read_timeout: Integer
25
+ # @rbs @write_timeout: Integer?
26
+ # @rbs @proxy_address: String?
27
+ # @rbs @proxy_port: Integer?
28
+
29
+ DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com" #: String
30
+ DEFAULT_OPEN_TIMEOUT = 10 #: Integer
31
+ DEFAULT_READ_TIMEOUT = 60 #: Integer
32
+
33
+ #: (?api_key: String?, ?base_url: String, ?open_timeout: Integer, ?read_timeout: Integer, ?write_timeout: Integer?, ?proxy_address: String?, ?proxy_port: Integer?) -> void
34
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, open_timeout: DEFAULT_OPEN_TIMEOUT,
35
+ read_timeout: DEFAULT_READ_TIMEOUT, write_timeout: nil,
36
+ proxy_address: nil, proxy_port: nil)
37
+ @api_key = api_key
38
+ @base_url = base_url
39
+ @open_timeout = open_timeout
40
+ @read_timeout = read_timeout
41
+ @write_timeout = write_timeout
42
+ @proxy_address = proxy_address
43
+ @proxy_port = proxy_port
44
+ end
45
+
46
+ # POSTs a JSON body to an API path and returns the parsed response hash.
47
+ # Raises Riffer::Error when the API responds with a non-success status.
48
+ #--
49
+ #: (String, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
50
+ def post(path, body)
51
+ uri = URI("#{@base_url}/#{path}")
52
+ response = start_http(uri) { |http| http.request(build_request(uri, body)) }
53
+ handle_api_error!(response) unless response.is_a?(Net::HTTPSuccess)
54
+ JSON.parse(response.body, symbolize_names: true)
55
+ end
56
+
57
+ # POSTs a JSON body to an API path, yielding raw response body chunks as
58
+ # they arrive. Raises Riffer::Error when the API responds with a
59
+ # non-success status.
60
+ #--
61
+ #: (String, Hash[Symbol, untyped]) { (String) -> void } -> void
62
+ def post_stream(path, body, &block)
63
+ uri = URI("#{@base_url}/#{path}")
64
+ start_http(uri) do |http|
65
+ http.request(build_request(uri, body)) do |response|
66
+ handle_api_error!(response) unless response.is_a?(Net::HTTPSuccess)
67
+
68
+ begin
69
+ response.read_body(&block)
70
+ rescue IOError
71
+ # A pre-buffered body (VCR/WebMock playback) raises IOError on a
72
+ # streaming read; hand over the full body instead.
73
+ yield(response.body)
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ #--
82
+ #: (URI::Generic, Hash[Symbol, untyped]) -> Net::HTTP::Post
83
+ def build_request(uri, body)
84
+ request = Net::HTTP::Post.new(uri)
85
+ request["Content-Type"] = "application/json"
86
+ request["x-goog-api-key"] = @api_key
87
+ request.body = body.to_json
88
+ request
89
+ end
90
+
91
+ #--
92
+ #: [R] (URI::Generic) { (Net::HTTP) -> R } -> R
93
+ def start_http(uri, &)
94
+ host = uri.hostname #: String
95
+ options = {
96
+ use_ssl: uri.scheme == "https",
97
+ open_timeout: @open_timeout,
98
+ read_timeout: @read_timeout,
99
+ } #: Hash[Symbol, untyped]
100
+ options[:write_timeout] = @write_timeout if @write_timeout
101
+
102
+ if @proxy_address
103
+ Net::HTTP.start(host, uri.port, @proxy_address, @proxy_port, nil, nil, **options, &)
104
+ else
105
+ Net::HTTP.start(host, uri.port, **options, &)
106
+ end
107
+ end
108
+
109
+ #--
110
+ #: (Net::HTTPResponse) -> void
111
+ def handle_api_error!(response)
112
+ parsed = begin
113
+ JSON.parse(response.body, symbolize_names: true)
114
+ rescue JSON::ParserError
115
+ { message: response.body }
116
+ end
117
+ error_message = parsed.dig(:error, :message) || parsed[:message] || response.body
118
+ raise Riffer::Error, "Gemini API error (#{response.code}): #{error_message}"
119
+ end
120
+ end
@@ -2,20 +2,11 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  require "json"
5
- require "net/http"
6
5
  require "securerandom"
7
- require "uri"
8
6
 
9
7
  # Google Gemini provider for Gemini models via the Gemini REST API.
10
8
  class Riffer::Providers::Gemini < Riffer::Providers::Base
11
- # @rbs @api_key: String?
12
- # @rbs @open_timeout: Integer
13
- # @rbs @read_timeout: Integer
14
-
15
- BASE_URI = URI("https://generativelanguage.googleapis.com") #: URI::Generic
16
9
  VALID_MODEL_PATTERN = /\A[a-zA-Z0-9._-]+\z/ #: Regexp
17
- DEFAULT_OPEN_TIMEOUT = 10 #: Integer
18
- DEFAULT_READ_TIMEOUT = 60 #: Integer
19
10
 
20
11
  FINISH_REASONS = {
21
12
  "STOP" => :stop,
@@ -36,17 +27,19 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
36
27
  "gcp.gemini"
37
28
  end
38
29
 
30
+ private
31
+
39
32
  #--
40
- #: (?api_key: String?, ?open_timeout: Integer?, ?read_timeout: Integer?, **untyped) -> void
41
- def initialize(api_key: nil, open_timeout: nil, read_timeout: nil, **_options)
42
- super()
43
- api_key ||= Riffer.config.gemini.api_key
44
- @api_key = api_key
45
- @open_timeout = open_timeout || Riffer.config.gemini.open_timeout || DEFAULT_OPEN_TIMEOUT
46
- @read_timeout = read_timeout || Riffer.config.gemini.read_timeout || DEFAULT_READ_TIMEOUT
33
+ #: () -> untyped
34
+ def global_client
35
+ Riffer.config.gemini.client
47
36
  end
48
37
 
49
- private
38
+ #--
39
+ #: () -> untyped
40
+ def build_client
41
+ Riffer::Providers::Gemini::Client.new(**{ api_key: Riffer.config.gemini.api_key }.compact)
42
+ end
50
43
 
51
44
  #--
52
45
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
@@ -89,9 +82,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
89
82
  def execute_generate(params)
90
83
  model = params[:model]
91
84
  body = params.except(:model)
92
- response = post_request(api_path(model, "generateContent"), body)
93
- handle_api_error!(response) unless response.is_a?(Net::HTTPSuccess)
94
- JSON.parse(response.body, symbolize_names: true)
85
+ client.post(api_path(model, "generateContent"), body)
95
86
  end
96
87
 
97
88
  #--
@@ -171,13 +162,6 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
171
162
  model = params[:model]
172
163
  body = params.except(:model)
173
164
 
174
- uri = URI("#{BASE_URI}/#{api_path(model, 'streamGenerateContent')}?alt=sse")
175
- host = uri.hostname #: String
176
- request = Net::HTTP::Post.new(uri)
177
- request["Content-Type"] = "application/json"
178
- request["x-goog-api-key"] = @api_key
179
- request.body = body.to_json
180
-
181
165
  full_text = +""
182
166
  buffer = +""
183
167
  raw_finish_reason = nil #: String?
@@ -224,17 +208,8 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
224
208
  end
225
209
  end
226
210
 
227
- Net::HTTP.start(host, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
228
- http.request(request) do |response|
229
- handle_api_error!(response) unless response.is_a?(Net::HTTPSuccess)
230
-
231
- begin
232
- response.read_body { |chunk| process_chunk.call(chunk) }
233
- rescue IOError
234
- process_chunk.call(response.body)
235
- end
236
- end
237
- end
211
+ path = "#{api_path(model, 'streamGenerateContent')}?alt=sse"
212
+ client.post_stream(path, body) { |chunk| process_chunk.call(chunk) }
238
213
 
239
214
  yielder << Riffer::StreamEvents::TextDone.new(full_text) unless full_text.empty?
240
215
  yield_finish_reason(yielder, build_finish_reason(raw_finish_reason, tool_calls: saw_function_call))
@@ -325,18 +300,6 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
325
300
  args.is_a?(String) ? args : args.to_json
326
301
  end
327
302
 
328
- #--
329
- #: (String, Hash[Symbol, untyped]) -> Net::HTTPResponse
330
- def post_request(path, body)
331
- uri = URI("#{BASE_URI}/#{path}")
332
- host = uri.hostname #: String
333
- request = Net::HTTP::Post.new(uri)
334
- request["Content-Type"] = "application/json"
335
- request["x-goog-api-key"] = @api_key
336
- request.body = body.to_json
337
- Net::HTTP.start(host, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) { |http| http.request(request) }
338
- end
339
-
340
303
  #--
341
304
  #: (String, String) -> String
342
305
  def api_path(model, method)
@@ -370,16 +333,4 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
370
333
 
371
334
  schema
372
335
  end
373
-
374
- #--
375
- #: (Net::HTTPResponse) -> void
376
- def handle_api_error!(response)
377
- body = begin
378
- JSON.parse(response.body, symbolize_names: true)
379
- rescue JSON::ParserError
380
- { message: response.body }
381
- end
382
- error_message = body.dig(:error, :message) || body[:message] || response.body
383
- raise Riffer::Error, "Gemini API error (#{response.code}): #{error_message}"
384
- end
385
336
  end
@@ -28,7 +28,8 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
28
28
  attr_reader :calls #: Array[Hash[Symbol, untyped]]
29
29
 
30
30
  # +responses:+ pre-configures canned responses (same shape as
31
- # +#stub_response+), typically set via +provider_options responses: [...]+.
31
+ # +#stub_response+) for standalone use; agent tests queue responses on
32
+ # <tt>agent.provider</tt> via +#stub_response+ instead.
32
33
  #
33
34
  # Riffer::Providers::Mock.new(responses: [
34
35
  # {content: "", tool_calls: [{name: "tool_a", arguments: "{}"}]},
@@ -36,10 +37,10 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
36
37
  # ])
37
38
  #
38
39
  #--
39
- #: (**untyped) -> void
40
- def initialize(**options)
40
+ #: (?responses: Array[Hash[Symbol, untyped]]) -> void
41
+ def initialize(responses: [])
41
42
  super()
42
- @responses = (options[:responses] || []).map { |r| normalize_response(r) }
43
+ @responses = responses.map { |r| normalize_response(r) }
43
44
  @current_index = 0
44
45
  @calls = []
45
46
  @stubbed_responses = []
@@ -13,17 +13,32 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
13
13
  end
14
14
 
15
15
  #--
16
- #: (**untyped) -> void
17
- def initialize(**options)
18
- super()
16
+ #: () -> void
17
+ def initialize
18
+ super
19
19
  depends_on "openai"
20
-
21
- api_key = options.fetch(:api_key, Riffer.config.openai.api_key)
22
- @client = ::OpenAI::Client.new(api_key: api_key, **options.except(:api_key))
23
20
  end
24
21
 
25
22
  private
26
23
 
24
+ #--
25
+ #: () -> untyped
26
+ def global_client
27
+ Riffer.config.openai.client
28
+ end
29
+
30
+ # Compacted so an unset value stays absent: the SDK reads +OPENAI_API_KEY+ /
31
+ # +OPENAI_BASE_URL+ only for a missing argument, and an explicit nil would
32
+ # suppress that fallback.
33
+ #--
34
+ #: () -> untyped
35
+ def build_client
36
+ ::OpenAI::Client.new(**{
37
+ api_key: Riffer.config.openai.api_key,
38
+ base_url: Riffer.config.openai.base_url,
39
+ }.compact)
40
+ end
41
+
27
42
  #--
28
43
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
29
44
  def build_request_params(messages, model, options)
@@ -81,7 +96,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
81
96
  #--
82
97
  #: (Hash[Symbol, untyped]) -> untyped
83
98
  def execute_generate(params)
84
- @client.responses.create(params)
99
+ client.responses.create(params)
85
100
  end
86
101
 
87
102
  #--
@@ -190,7 +205,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
190
205
  tool_info: {},
191
206
  } #: Hash[Symbol, untyped]
192
207
 
193
- stream = @client.responses.stream(params)
208
+ stream = client.responses.stream(params)
194
209
  begin
195
210
  stream.each do |event|
196
211
  case event.type
@@ -5,7 +5,7 @@ require "json"
5
5
 
6
6
  # OpenRouter provider (https://openrouter.ai). Requires the +openai+ gem —
7
7
  # OpenRouter exposes an OpenAI-compatible endpoint, so this reuses the OpenAI
8
- # SDK with a +base_url+ override. +api_key+ falls back to config, then
8
+ # SDK with a +base_url+ override. +api_key+ resolves from config, then
9
9
  # +OPENROUTER_API_KEY+.
10
10
  class Riffer::Providers::OpenRouter < Riffer::Providers::Base
11
11
  BASE_URL = "https://openrouter.ai/api/v1" #: String
@@ -27,17 +27,31 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
27
27
  end
28
28
 
29
29
  #--
30
- #: (?api_key: String?, **untyped) -> void
31
- def initialize(api_key: nil, **)
32
- super()
30
+ #: () -> void
31
+ def initialize
32
+ super
33
33
  depends_on "openai"
34
+ end
34
35
 
35
- api_key ||= Riffer.config.openrouter.api_key || ENV.fetch("OPENROUTER_API_KEY", nil)
36
+ private
36
37
 
37
- @client = ::OpenAI::Client.new(api_key: api_key, base_url: BASE_URL, **)
38
+ #--
39
+ #: () -> untyped
40
+ def global_client
41
+ Riffer.config.openrouter.client
38
42
  end
39
43
 
40
- private
44
+ # Deliberately not compacted: this borrows the OpenAI SDK to talk to a
45
+ # different vendor, so omitting an unset +api_key+ would let the SDK fall
46
+ # back to +OPENAI_API_KEY+ and send an OpenAI credential to OpenRouter.
47
+ # Passing nil raises in the SDK instead. +OPENROUTER_API_KEY+ is read here
48
+ # rather than left to the SDK for the same reason.
49
+ #--
50
+ #: () -> untyped
51
+ def build_client
52
+ api_key = Riffer.config.openrouter.api_key || ENV.fetch("OPENROUTER_API_KEY", nil)
53
+ ::OpenAI::Client.new(api_key: api_key, base_url: BASE_URL)
54
+ end
41
55
 
42
56
  #--
43
57
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
@@ -84,7 +98,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
84
98
  #--
85
99
  #: (Hash[Symbol, untyped]) -> untyped
86
100
  def execute_generate(params)
87
- @client.chat.completions.create(**params)
101
+ client.chat.completions.create(**params)
88
102
  end
89
103
 
90
104
  #--
@@ -174,7 +188,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
174
188
  # events. We want raw ChatCompletionChunk objects with
175
189
  # +choices.first.delta+ so we can map deltas to Riffer::StreamEvents
176
190
  # ourselves.
177
- stream = @client.chat.completions.stream_raw(**stream_params)
191
+ stream = client.chat.completions.stream_raw(**stream_params)
178
192
  begin
179
193
  stream.each do |chunk|
180
194
  handle_stream_chunk(chunk, state: state, yielder: yielder)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Registry of a class's named direct subclasses, keyed by identifier. Extend it
5
+ # onto a base class to look up subclasses in constant time via +find+ and +all+.
6
+ #
7
+ # class Riffer::Tool
8
+ # extend Riffer::Registrable
9
+ # end
10
+ #
11
+ # Riffer::Tool.find("weather_tool") # => WeatherTool
12
+ #
13
+ # @rbs module-self Class
14
+ module Riffer::Registrable
15
+ # @rbs @identifier_registry: Hash[String, Class]?
16
+
17
+ # Finds a registered subclass by identifier, or +nil+ when none matches.
18
+ # Only *named direct* subclasses are registered: grandchildren are not
19
+ # visible to a grandparent's +find+ (call +find+ on their direct parent
20
+ # instead), anonymous classes are never registered, and duplicate identifiers
21
+ # raise Riffer::DuplicateIdentifierError at first lookup.
22
+ #
23
+ #--
24
+ #: (String | Symbol) -> Class?
25
+ def find(identifier)
26
+ identifier_registry[identifier.to_s]
27
+ end
28
+
29
+ # Returns all registered subclasses. Only *named direct* subclasses are
30
+ # registered: grandchildren are not included (call +all+ on their direct
31
+ # parent instead), anonymous classes are never registered, and duplicate
32
+ # identifiers raise Riffer::DuplicateIdentifierError at first lookup.
33
+ #
34
+ #--
35
+ #: () -> Array[Class]
36
+ def all
37
+ identifier_registry.values
38
+ end
39
+
40
+ private
41
+
42
+ # Ruby invokes +inherited+ with +self+ bound to the direct superclass — the
43
+ # only registry the new subclass joins — so busting self's memo is exactly
44
+ # sufficient.
45
+ #--
46
+ #: (Class) -> void
47
+ def inherited(subclass)
48
+ super
49
+ @identifier_registry = nil
50
+ end
51
+
52
+ #--
53
+ #: () -> Hash[String, Class]
54
+ def identifier_registry
55
+ @identifier_registry ||= build_identifier_registry
56
+ end
57
+
58
+ #--
59
+ #: () -> Hash[String, Class]
60
+ def build_identifier_registry
61
+ registry = {} #: Hash[String, Class]
62
+ subclasses.each_with_object(registry) do |subclass, acc|
63
+ # Anonymous classes are skipped even with an explicit identifier — the
64
+ # MCP factory and serializer shells synthesize short-lived anonymous
65
+ # classes whose registration would flake with GC timing.
66
+ next if Riffer::Helpers::Identifier.for(subclass).empty?
67
+
68
+ candidate = subclass #: untyped
69
+ key = candidate.identifier.to_s
70
+ next if key.strip.empty?
71
+
72
+ existing = acc[key]
73
+ if existing
74
+ raise Riffer::DuplicateIdentifierError,
75
+ "Duplicate identifier #{key.inspect} for #{existing} and #{subclass}"
76
+ end
77
+
78
+ acc[key] = subclass
79
+ end.freeze
80
+ end
81
+ end
data/lib/riffer/tool.rb CHANGED
@@ -21,6 +21,7 @@ require "timeout"
21
21
  #
22
22
  class Riffer::Tool
23
23
  extend Riffer::Tools::Toolable
24
+ extend Riffer::Registrable
24
25
 
25
26
  kind :tool
26
27
 
@@ -15,6 +15,7 @@
15
15
  # end
16
16
  # end
17
17
  #
18
+ # @rbs module-self Module
18
19
  module Riffer::Tools::Toolable
19
20
  # @rbs self.@extenders: Array[Module]?
20
21
  # @rbs @description: String?
@@ -57,9 +58,7 @@ module Riffer::Tools::Toolable
57
58
  #--
58
59
  #: (?String?) -> String
59
60
  def identifier(value = nil)
60
- if value.nil?
61
- return @identifier || Riffer::Helpers::ClassNameConverter.convert(Module.instance_method(:name).bind_call(self))
62
- end
61
+ return @identifier || Riffer::Helpers::Identifier.for(self) if value.nil?
63
62
 
64
63
  @identifier = value.to_s
65
64
  end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.40.0" #: String
5
+ VERSION = "0.42.0" #: String
6
6
  end
data/lib/riffer.rb CHANGED
@@ -32,6 +32,9 @@ module Riffer
32
32
  # Raised when a tool encounters an expected execution error.
33
33
  class ToolExecutionError < Error; end
34
34
 
35
+ # Raised when two registered subclasses share the same identifier.
36
+ class DuplicateIdentifierError < Error; end
37
+
35
38
  # Returns the Riffer configuration.
36
39
  #
37
40
  #--