riffer-rig 0.7.0 → 0.9.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +13 -3
  4. data/lib/riffer/rig/cli/flags.rb +16 -2
  5. data/lib/riffer/rig/cli.rb +2 -1
  6. data/lib/riffer/rig/commands/auth.rb +79 -0
  7. data/lib/riffer/rig/commands/model.rb +70 -21
  8. data/lib/riffer/rig/commands/reload.rb +23 -0
  9. data/lib/riffer/rig/credentials.rb +13 -1
  10. data/lib/riffer/rig/extension.rb +12 -0
  11. data/lib/riffer/rig/loader.rb +327 -23
  12. data/lib/riffer/rig/provider_setup.rb +36 -5
  13. data/lib/riffer/rig/providers.rb +48 -0
  14. data/lib/riffer/rig/registrar.rb +8 -0
  15. data/lib/riffer/rig/runtime.rb +110 -21
  16. data/lib/riffer/rig/sdk.rb +62 -0
  17. data/lib/riffer/rig/settings/document.rb +9 -1
  18. data/lib/riffer/rig/settings.rb +5 -4
  19. data/lib/riffer/rig/stores/jsonl.rb +115 -0
  20. data/lib/riffer/rig/stores/recorder.rb +119 -0
  21. data/lib/riffer/rig/terminal/renderer.rb +86 -13
  22. data/lib/riffer/rig/terminal/wrapper.rb +103 -0
  23. data/lib/riffer/rig/trust.rb +37 -0
  24. data/lib/riffer/rig/version.rb +1 -1
  25. data/lib/riffer/rig.rb +7 -1
  26. data/sig/generated/riffer/rig/cli/flags.rbs +5 -2
  27. data/sig/generated/riffer/rig/commands/auth.rbs +35 -0
  28. data/sig/generated/riffer/rig/commands/model.rbs +21 -9
  29. data/sig/generated/riffer/rig/commands/reload.rbs +12 -0
  30. data/sig/generated/riffer/rig/credentials.rbs +8 -0
  31. data/sig/generated/riffer/rig/extension.rbs +4 -0
  32. data/sig/generated/riffer/rig/loader.rbs +142 -5
  33. data/sig/generated/riffer/rig/provider_setup.rbs +15 -4
  34. data/sig/generated/riffer/rig/providers.rbs +28 -0
  35. data/sig/generated/riffer/rig/registrar.rbs +6 -0
  36. data/sig/generated/riffer/rig/runtime.rbs +63 -9
  37. data/sig/generated/riffer/rig/sdk.rbs +33 -0
  38. data/sig/generated/riffer/rig/settings/document.rbs +7 -1
  39. data/sig/generated/riffer/rig/settings.rbs +2 -3
  40. data/sig/generated/riffer/rig/stores/jsonl.rbs +64 -0
  41. data/sig/generated/riffer/rig/stores/recorder.rbs +67 -0
  42. data/sig/generated/riffer/rig/terminal/renderer.rbs +41 -1
  43. data/sig/generated/riffer/rig/terminal/wrapper.rbs +43 -0
  44. data/sig/generated/riffer/rig/trust.rbs +20 -0
  45. data/sig/generated/riffer/rig.rbs +4 -0
  46. data/sig/manual/riffer/rig/commands/auth.rbs +6 -0
  47. data/sig/manual/riffer/rig/commands/reload.rbs +6 -0
  48. data/sig/manual/riffer/rig/providers.rbs +6 -0
  49. data/sig/manual/riffer/rig/sdk.rbs +6 -0
  50. data/sig/manual/riffer/rig/stores.rbs +11 -0
  51. data/sig/manual/riffer/rig/trust.rbs +6 -0
  52. metadata +23 -29
@@ -3,21 +3,27 @@
3
3
  class Riffer::Rig::Loader
4
4
  class ConfigurationError < StandardError; end
5
5
 
6
- PROVIDER_LIST = Riffer::Rig::Settings::PROVIDERS.join(', ') #: String
6
+ # Raised by a strict load: the reload abandons instead of continuing.
7
+ class AbandonedError < StandardError; end
7
8
 
8
- ONBOARDING_QUESTION = 'Which model should riffer use? Enter provider/name, ' \
9
- "with a provider from: #{PROVIDER_LIST}".freeze #: String
9
+ EXTENSION_FEATURE = 'riffer/rig/extension' #: String
10
10
 
11
- NO_MODEL = 'No model is set: pass --model provider/name, set RIFFER_MODEL, or set "model" in ' \
12
- "~/.riffer/settings.json, with a provider from: #{PROVIDER_LIST}".freeze #: String
13
-
14
- private_constant :PROVIDER_LIST, :ONBOARDING_QUESTION, :NO_MODEL
11
+ private_constant :EXTENSION_FEATURE
15
12
 
16
13
  # @rbs @cwd: String
17
14
  # @rbs @host: Riffer::Rig::Hosts::_Host
18
15
  # @rbs @env: Riffer::Rig::Env
19
16
  # @rbs @home: String
20
17
  # @rbs @riffer_config: Riffer::Config
18
+ # @rbs @store: Riffer::Rig::Stores::_Store | nil
19
+ # @rbs @tracked_files: Array[String]
20
+ # @rbs @stripped: Array[Symbol]
21
+ # @rbs @extensions_enabled: bool
22
+ # @rbs @gem_extensions: Array[Riffer::Rig::Extension]
23
+ # @rbs @file_state: Hash[String, Time?]?
24
+
25
+ # @dynamic tracked_files
26
+ attr_reader :tracked_files #: Array[String]
21
27
 
22
28
  # @rbs cwd: String
23
29
  # @rbs host: Riffer::Rig::Hosts::_Host
@@ -30,6 +36,7 @@ class Riffer::Rig::Loader
30
36
  # @rbs agents_md: bool
31
37
  # @rbs tools: Array[String]?
32
38
  # @rbs max_steps: Integer?
39
+ # @rbs store: Riffer::Rig::Stores::_Store | nil
33
40
  # @rbs return: Riffer::Rig::Runtime
34
41
  def self.runtime(
35
42
  cwd:,
@@ -42,9 +49,11 @@ class Riffer::Rig::Loader
42
49
  skills: true,
43
50
  agents_md: true,
44
51
  tools: nil,
45
- max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS
52
+ max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS,
53
+ store: Riffer::Rig::Stores::JSONL.new
46
54
  )
47
- new(cwd:, host:, env:, home:, riffer_config:).runtime(model:, extensions:, skills:, agents_md:, tools:, max_steps:)
55
+ new(cwd:, host:, env:, home:, riffer_config:, store:)
56
+ .runtime(model:, extensions:, skills:, agents_md:, tools:, max_steps:)
48
57
  end
49
58
 
50
59
  # @rbs cwd: String
@@ -52,8 +61,16 @@ class Riffer::Rig::Loader
52
61
  # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
53
62
  # @rbs home: String
54
63
  # @rbs riffer_config: Riffer::Config
64
+ # @rbs store: Riffer::Rig::Stores::_Store | nil
55
65
  # @rbs return: void
56
- def initialize(cwd:, host:, env: Riffer::Rig::Env.load, home: Dir.home, riffer_config: Riffer.config)
66
+ def initialize(
67
+ cwd:,
68
+ host:,
69
+ store: Riffer::Rig::Stores::JSONL.new,
70
+ env: Riffer::Rig::Env.load,
71
+ home: Dir.home,
72
+ riffer_config: Riffer.config
73
+ )
57
74
  raise ConfigurationError, env.message if env.is_a?(Riffer::Rig::Env::Invalid)
58
75
 
59
76
  @cwd = cwd
@@ -61,6 +78,12 @@ class Riffer::Rig::Loader
61
78
  @env = env
62
79
  @home = home
63
80
  @riffer_config = riffer_config
81
+ @store = store
82
+ @tracked_files = []
83
+ @stripped = []
84
+ @extensions_enabled = true
85
+ @gem_extensions = []
86
+ @file_state = nil
64
87
  end
65
88
 
66
89
  # @rbs model: String?
@@ -78,19 +101,25 @@ class Riffer::Rig::Loader
78
101
  tools: nil,
79
102
  max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS
80
103
  )
81
- settings = Riffer::Rig::Settings.merge(
82
- Riffer::Rig::Settings.read(home_settings_path),
83
- Riffer::Rig::Settings.read(File.join(@cwd, '.riffer', 'settings.json'))
84
- )
104
+ settings = merged_settings
85
105
  document = Riffer::Rig::Settings::Document.new(settings)
86
106
  selected = select_model(model || @env.model || document.model)
87
107
  provider = Riffer::Rig::Settings.provider_for(selected).to_s
108
+ ensure_sdk(provider)
88
109
  credentials = { provider.to_sym => credentials_for(provider) }
89
- stripped = { skills: skills, agents_md: agents_md }.reject { |_name, kept| kept }.keys
110
+ @extensions_enabled = extensions
111
+ @stripped = { skills: skills, agents_md: agents_md }.reject { |_name, kept| kept }.keys
112
+ loaded = Riffer::Rig::Bundled::BY_NAME.except(*document.disabled.map(&:to_sym), *@stripped).values
113
+ @tracked_files = []
114
+ @gem_extensions = []
115
+ loaded += load_rig_files(document.autoload) if extensions
116
+
117
+ recorder = recorder_for(document)
118
+ loaded << Riffer::Rig::Stores::Recorder.extension(recorder) if recorder
90
119
 
91
- Riffer::Rig::Runtime.new(
120
+ built = Riffer::Rig::Runtime.new(
92
121
  selected,
93
- extensions: Riffer::Rig::Bundled::BY_NAME.except(*document.disabled.map(&:to_sym), *stripped).values,
122
+ extensions: loaded,
94
123
  tools: tools,
95
124
  settings: settings,
96
125
  host: @host,
@@ -99,12 +128,248 @@ class Riffer::Rig::Loader
99
128
  credentials: credentials,
100
129
  pricing: document.models,
101
130
  riffer_config: @riffer_config,
102
- model_options: Riffer::Rig::Settings.model_options(selected, document.reasoning)
131
+ model_options: Riffer::Rig::Settings.model_options(selected, document.reasoning),
132
+ native_tools: document.native_tools
103
133
  )
134
+ built.install_command(Riffer::Rig::Commands::Reload.command(self))
135
+ @file_state = file_state
136
+ recorder&.attach(built)
137
+ built
138
+ end
139
+
140
+ # Without force, a no-op while the tracked file set and the two rig.rb paths
141
+ # look as the last discovery left them — the entry point the automatic
142
+ # trigger (#133) calls. The snapshot advances on every attempt, failed ones
143
+ # included, so a failure does not retry every request. Failures are notified
144
+ # once and reported as the message, the way Settings.rejection reports a
145
+ # rejection; the Runtime is the success value.
146
+ # @rbs runtime: Riffer::Rig::Runtime
147
+ # @rbs force: bool
148
+ # @rbs return: (Riffer::Rig::Runtime | String)
149
+ def reload(runtime, force: false)
150
+ return runtime if !force && file_state == @file_state
151
+
152
+ outcome = perform_reload(runtime)
153
+ @file_state = file_state
154
+ outcome
104
155
  end
105
156
 
106
157
  private
107
158
 
159
+ # @rbs return: Hash[Symbol, untyped]
160
+ def merged_settings
161
+ Riffer::Rig::Settings.merge(
162
+ Riffer::Rig::Settings.read(home_settings_path),
163
+ Riffer::Rig::Settings.read(File.join(@cwd, '.riffer', 'settings.json'))
164
+ )
165
+ end
166
+
167
+ # Notifies every failure once and reports its message; a rebuilt Runtime is
168
+ # the success value.
169
+ # @rbs runtime: Riffer::Rig::Runtime
170
+ # @rbs return: (Riffer::Rig::Runtime | String)
171
+ def perform_reload(runtime)
172
+ settings = merged_settings
173
+ document = Riffer::Rig::Settings::Document.new(settings)
174
+ begin
175
+ extensions = reload_extensions(document, settings)
176
+ rescue AbandonedError => e
177
+ return notify_failure(e.message)
178
+ end
179
+
180
+ failure = reload_credentials(runtime)
181
+ return notify_failure(failure) if failure
182
+
183
+ begin
184
+ runtime.rebuild(extensions: validated(extensions, settings), settings: settings)
185
+ rescue StandardError => e
186
+ return notify_failure(e.message)
187
+ end
188
+ runtime
189
+ end
190
+
191
+ # @rbs message: String
192
+ # @rbs return: String
193
+ def notify_failure(message)
194
+ @host.notify(message, level: :error)
195
+ message
196
+ end
197
+
198
+ # @rbs document: Riffer::Rig::Settings::Document
199
+ # @rbs settings: Hash[Symbol, untyped]
200
+ # @rbs return: Array[Riffer::Rig::Extension]
201
+ def reload_extensions(document, settings)
202
+ candidates = Riffer::Rig::Bundled::BY_NAME.except(*document.disabled.map(&:to_sym), *@stripped).values
203
+ return validated(candidates, settings) unless @extensions_enabled
204
+
205
+ scrub_tracked_features
206
+ @tracked_files = []
207
+ candidates.concat(@gem_extensions, rig_recordings)
208
+ validated(candidates, settings)
209
+ end
210
+
211
+ # @rbs return: Array[Riffer::Rig::Extension]
212
+ def rig_recordings
213
+ recorded = [] #: Array[Riffer::Rig::Extension]
214
+ recorded.concat(load_rig_file(home_rig_path, confirm_trust: false))
215
+ recorded.concat(load_rig_file(project_rig_path, confirm_trust: true))
216
+ recorded
217
+ end
218
+
219
+ # @rbs path: String
220
+ # @rbs confirm_trust: bool
221
+ # @rbs return: Array[Riffer::Rig::Extension]
222
+ def load_rig_file(path, confirm_trust:)
223
+ return [] unless File.file?(path)
224
+ return [] if confirm_trust && !trusted?(path)
225
+
226
+ load_file(path, track: true, strict: true)
227
+ end
228
+
229
+ # @rbs return: void
230
+ def scrub_tracked_features
231
+ @tracked_files.each { |path| $LOADED_FEATURES.delete(path) }
232
+ end
233
+
234
+ # Runs each extension against a throwaway registrar so that a failing block
235
+ # is skipped and reported instead of aborting the rebuild.
236
+ # @rbs extensions: Array[Riffer::Rig::Extension]
237
+ # @rbs settings: Hash[Symbol, untyped]
238
+ # @rbs return: Array[Riffer::Rig::Extension]
239
+ def validated(extensions, settings)
240
+ extensions.select do |extension|
241
+ registrar = Riffer::Rig::Registrar.new(extension.name, settings[extension.name.to_sym] || {})
242
+ error = extension.load_into(registrar)
243
+ next true unless error
244
+
245
+ @host.notify("Extension #{extension.name} failed to load: #{error.message}", level: :error)
246
+ false
247
+ end
248
+ end
249
+
250
+ # @rbs runtime: Riffer::Rig::Runtime
251
+ # @rbs return: String?
252
+ def reload_credentials(runtime)
253
+ provider = runtime.model.partition('/').first
254
+ resolution = Riffer::Rig::Credentials.resolve(
255
+ provider,
256
+ host: asking_host,
257
+ env: @env,
258
+ auth_path: File.join(@home, '.riffer', 'auth.json'),
259
+ settings_path: home_settings_path
260
+ )
261
+ return missing_credentials(provider, resolution.missing) unless resolution.missing.empty?
262
+
263
+ Riffer::Rig::Credentials.apply(provider, resolution.values, config: @riffer_config)
264
+ runtime.merge_credentials(provider.to_sym, resolution.values)
265
+ nil
266
+ end
267
+
268
+ # @rbs return: Hash[String, Time?]
269
+ def file_state
270
+ (@tracked_files + rig_paths).uniq.to_h { |path| [path, stamp(path)] }
271
+ end
272
+
273
+ # @rbs return: Array[String]
274
+ def rig_paths
275
+ return [] unless @extensions_enabled
276
+
277
+ [home_rig_path, project_rig_path]
278
+ end
279
+
280
+ # @rbs path: String
281
+ # @rbs return: Time?
282
+ def stamp(path)
283
+ File.file?(path) ? File.mtime(path) : nil
284
+ end
285
+
286
+ # @rbs return: String
287
+ def home_rig_path
288
+ File.expand_path(File.join(@home, '.riffer', 'rig.rb'))
289
+ end
290
+
291
+ # @rbs return: String
292
+ def project_rig_path
293
+ File.expand_path(File.join(@cwd, '.riffer', 'rig.rb'))
294
+ end
295
+
296
+ # @rbs autoload: bool
297
+ # @rbs return: Array[Riffer::Rig::Extension]
298
+ def load_rig_files(autoload)
299
+ @gem_extensions = gem_extension_files(autoload).flat_map { |file| load_file(File.expand_path(file), track: false) }
300
+ recorded = @gem_extensions.dup
301
+ recorded.concat(load_file(home_rig_path, track: true)) if File.file?(home_rig_path)
302
+ if File.file?(project_rig_path) && trusted?(project_rig_path)
303
+ recorded.concat(load_file(project_rig_path, track: true))
304
+ end
305
+ recorded
306
+ end
307
+
308
+ # @rbs autoload: bool
309
+ # @rbs return: Array[String]
310
+ def gem_extension_files(autoload)
311
+ return [] unless autoload
312
+
313
+ Gem.find_files(EXTENSION_FEATURE)
314
+ end
315
+
316
+ # @rbs path: String
317
+ # @rbs return: bool
318
+ def trusted?(path)
319
+ stored = Riffer::Rig::Trust.read(trust_path)[path]
320
+ return stored unless stored.nil?
321
+
322
+ asked = @host.capabilities.include?(:confirm)
323
+ decision = asked ? @host.confirm("Trust #{path}?") : false
324
+ Riffer::Rig::Trust.store(trust_path, path, decision) if asked
325
+ decision
326
+ end
327
+
328
+ # @rbs return: String
329
+ def trust_path
330
+ File.join(@home, '.riffer', 'trust.json')
331
+ end
332
+
333
+ # Loads one file and returns the extensions it recorded. A tracked load adds
334
+ # the features the load added to the tracked file set. A failure is reported
335
+ # to the host and yields nothing, unless strict: then it propagates as
336
+ # AbandonedError for the reload to abandon on.
337
+ # @rbs path: String
338
+ # @rbs track: bool
339
+ # @rbs strict: bool
340
+ # @rbs return: Array[Riffer::Rig::Extension]
341
+ def load_file(path, track:, strict: false)
342
+ before = Riffer::Rig.extensions
343
+ before_features = $LOADED_FEATURES.dup
344
+ begin
345
+ load path
346
+ rescue StandardError => e
347
+ track_loaded(before_features, path) if track
348
+ message = "#{path} failed to load: #{e.message}"
349
+ raise AbandonedError, message if strict
350
+
351
+ @host.notify(message, level: :error)
352
+ return []
353
+ end
354
+ track_loaded(before_features, path) if track
355
+ Riffer::Rig.extensions.values.reject { |extension| before[extension.name] == extension }
356
+ end
357
+
358
+ # @rbs before_features: Array[String]
359
+ # @rbs path: String
360
+ # @rbs return: void
361
+ def track_loaded(before_features, path)
362
+ recorded = ($LOADED_FEATURES - before_features) + [path] #: Array[String]
363
+ @tracked_files.concat(recorded.select { |feature| local?(feature) })
364
+ end
365
+
366
+ # @rbs feature: String
367
+ # @rbs return: bool
368
+ def local?(feature)
369
+ roots = [File.expand_path(File.join(@home, '.riffer')), File.expand_path(@cwd)] #: Array[String]
370
+ roots.any? { |root| feature.start_with?("#{root}#{File::SEPARATOR}") }
371
+ end
372
+
108
373
  # @rbs configured: String?
109
374
  # @rbs return: String
110
375
  def select_model(configured)
@@ -118,12 +383,40 @@ class Riffer::Rig::Loader
118
383
 
119
384
  # @rbs return: String
120
385
  def ask_for_model
121
- answer = asking_host.ask(ONBOARDING_QUESTION).to_s.strip
122
- raise ConfigurationError, NO_MODEL if answer.empty?
386
+ answer = asking_host.ask(onboarding_question).to_s.strip
387
+ raise ConfigurationError, no_model if answer.empty?
123
388
 
124
389
  answer
125
390
  end
126
391
 
392
+ # @rbs return: String
393
+ def onboarding_question
394
+ 'Which model should riffer use? Enter provider/name, ' \
395
+ "with a provider from: #{provider_list}"
396
+ end
397
+
398
+ # @rbs return: String
399
+ def no_model
400
+ 'No model is set: pass --model provider/name, set RIFFER_MODEL, or set "model" in ' \
401
+ "~/.riffer/settings.json, with a provider from: #{provider_list}"
402
+ end
403
+
404
+ # @rbs return: String
405
+ def provider_list
406
+ Riffer::Rig::Settings.providers.join(', ')
407
+ end
408
+
409
+ # @rbs provider: String
410
+ # @rbs return: void
411
+ def ensure_sdk(provider)
412
+ sdk = Riffer::Rig::ProviderSetup.for(provider).sdk
413
+ return unless sdk
414
+
415
+ gem, requirement = sdk
416
+ message = Riffer::Rig::SDK.ensure(gem, requirement, host: asking_host)
417
+ raise ConfigurationError, message if message
418
+ end
419
+
127
420
  # @rbs provider: String
128
421
  # @rbs return: Hash[Symbol, String]
129
422
  def credentials_for(provider)
@@ -145,9 +438,8 @@ class Riffer::Rig::Loader
145
438
  # @rbs return: String
146
439
  def missing_credentials(provider, missing)
147
440
  setup = Riffer::Rig::ProviderSetup.for(provider)
148
- fields = setup.fields.select { |field| missing.include?(field.name) }
149
- wanted = fields.map { |field| "#{field.name} (#{field.env.join(' or ')})" }.join(', ')
150
- "#{provider} has no #{wanted}; set it in the environment or run riffer interactively to paste it" \
441
+ "#{provider} has no #{setup.missing_fields(missing)}; " \
442
+ 'set it in the environment or run riffer interactively to paste it' \
151
443
  "#{" (create one at #{setup.url})" if setup.url}"
152
444
  end
153
445
 
@@ -160,4 +452,16 @@ class Riffer::Rig::Loader
160
452
  def home_settings_path
161
453
  File.join(@home, '.riffer', 'settings.json')
162
454
  end
455
+
456
+ # The store records unless it was declined with store: nil or globally with
457
+ # "sessions": {"save": false}.
458
+ # @rbs document: Riffer::Rig::Settings::Document
459
+ # @rbs return: Riffer::Rig::Stores::Recorder?
460
+ def recorder_for(document)
461
+ store = @store
462
+ return nil unless document.save
463
+ return nil unless store
464
+
465
+ Riffer::Rig::Stores::Recorder.new(store: store)
466
+ end
163
467
  end
@@ -1,18 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Riffer::Rig::ProviderSetup
4
- # @dynamic url, chain, fields
4
+ # @dynamic url, chain, sdk, fields
5
5
  attr_reader :url #: String?
6
6
  attr_reader :chain #: bool
7
+ attr_reader :sdk #: [String, String]?
7
8
  attr_reader :fields #: Array[Riffer::Rig::ProviderSetup::Field]
8
9
 
9
10
  # @rbs fields: Array[Riffer::Rig::ProviderSetup::Field]
10
11
  # @rbs url: String?
11
12
  # @rbs chain: bool
13
+ # @rbs sdk: [String, String]?
12
14
  # @rbs return: void
13
- def initialize(fields:, url: nil, chain: false)
15
+ def initialize(fields:, url: nil, chain: false, sdk: nil)
14
16
  @url = url
15
17
  @chain = chain
18
+ @sdk = sdk&.freeze
16
19
  @fields = fields.freeze
17
20
  freeze
18
21
  end
@@ -24,15 +27,17 @@ class Riffer::Rig::ProviderSetup
24
27
  nil
25
28
  end #: ^() -> String?
26
29
 
27
- # Upstream candidate: each riffer provider could declare its own credential
28
- # fields, and this table would go.
30
+ # Upstream candidate: riffer's providers could declare their own SDK gem and
31
+ # credential fields, and this table would go.
29
32
  TABLE = {
30
33
  anthropic: new(
31
34
  url: 'https://console.anthropic.com/settings/keys',
35
+ sdk: ['anthropic', '~> 1.69'],
32
36
  fields: [Field.new(name: :api_key, env: ['ANTHROPIC_API_KEY'], secret: true, required: true)]
33
37
  ),
34
38
  openai: new(
35
39
  url: 'https://platform.openai.com/api-keys',
40
+ sdk: ['openai', '~> 0.80'],
36
41
  fields: [
37
42
  Field.new(name: :api_key, env: ['OPENAI_API_KEY'], secret: true, required: true),
38
43
  Field.new(name: :base_url, env: ['OPENAI_BASE_URL'], secret: false, required: false)
@@ -44,10 +49,12 @@ class Riffer::Rig::ProviderSetup
44
49
  ),
45
50
  openrouter: new(
46
51
  url: 'https://openrouter.ai/keys',
52
+ sdk: ['openai', '~> 0.80'],
47
53
  fields: [Field.new(name: :api_key, env: ['OPENROUTER_API_KEY'], secret: true, required: true)]
48
54
  ),
49
55
  azure_openai: new(
50
56
  url: 'https://portal.azure.com',
57
+ sdk: ['openai', '~> 0.80'],
51
58
  fields: [
52
59
  Field.new(name: :endpoint, env: ['AZURE_OPENAI_ENDPOINT'], secret: false, required: true),
53
60
  Field.new(name: :api_key, env: ['AZURE_OPENAI_API_KEY'], secret: true, required: true)
@@ -56,6 +63,7 @@ class Riffer::Rig::ProviderSetup
56
63
  amazon_bedrock: new(
57
64
  url: 'https://console.aws.amazon.com/bedrock',
58
65
  chain: true,
66
+ sdk: ['aws-sdk-bedrockruntime', '~> 1.0'],
59
67
  fields: [
60
68
  Field.new(
61
69
  name: :region,
@@ -69,10 +77,33 @@ class Riffer::Rig::ProviderSetup
69
77
  )
70
78
  }.freeze #: Hash[Symbol, Riffer::Rig::ProviderSetup]
71
79
 
80
+ # @rbs missing: Array[Symbol]
81
+ # @rbs return: String
82
+ def missing_fields(missing)
83
+ fields.select { |field| missing.include?(field.name) }
84
+ .map { |field| "#{field.name} (#{field.env.join(' or ')})" }
85
+ .join(', ')
86
+ end
87
+
88
+ # @rbs hash: Hash[Symbol, untyped]
89
+ # @rbs return: Riffer::Rig::ProviderSetup
90
+ def self.from(hash)
91
+ fields = hash.fetch(:fields).map do |field|
92
+ Field.new(
93
+ name: field.fetch(:name),
94
+ env: field.fetch(:env),
95
+ secret: field.fetch(:secret, false),
96
+ required: field.fetch(:required, false),
97
+ fallback: field[:fallback]
98
+ )
99
+ end
100
+ new(fields: fields, url: hash[:url], chain: hash.fetch(:chain, false), sdk: hash[:sdk])
101
+ end
102
+
72
103
  # @rbs identifier: String | Symbol
73
104
  # @rbs return: Riffer::Rig::ProviderSetup?
74
105
  def self.[](identifier)
75
- TABLE[identifier.to_sym]
106
+ Riffer::Rig::Providers.setup(identifier) || TABLE[identifier.to_sym]
76
107
  end
77
108
 
78
109
  # @rbs identifier: String | Symbol
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Riffer::Rig::Providers
4
+ extend self
5
+
6
+ # Upstream candidate: riffer's Repository exposes no listing of extension
7
+ # registrations and stores no credential setups, so this registry mirrors
8
+ # what the provider seam registers.
9
+ # @rbs @setups: Hash[Symbol, Riffer::Rig::ProviderSetup?]
10
+ @setups = {} #: Hash[Symbol, Riffer::Rig::ProviderSetup?]
11
+
12
+ # @rbs identifier: String | Symbol
13
+ # @rbs setup: Riffer::Rig::ProviderSetup | Hash[Symbol, untyped]?
14
+ # @rbs &block: () -> singleton(::Riffer::Providers::Base)
15
+ # @rbs return: void
16
+ def register(identifier, setup: nil, &)
17
+ ::Riffer::Providers::Repository.register(identifier, &)
18
+ @setups[identifier.to_sym] = setup && normalize(setup)
19
+ nil
20
+ end
21
+
22
+ # @rbs identifier: String | Symbol
23
+ # @rbs return: void
24
+ def unregister(identifier)
25
+ ::Riffer::Providers::Repository.unregister(identifier)
26
+ @setups.delete(identifier.to_sym)
27
+ nil
28
+ end
29
+
30
+ # @rbs identifier: String | Symbol
31
+ # @rbs return: Riffer::Rig::ProviderSetup?
32
+ def setup(identifier)
33
+ @setups[identifier.to_sym]
34
+ end
35
+
36
+ # @rbs return: Array[Symbol]
37
+ def identifiers
38
+ (::Riffer::Providers::Repository::REPO.keys - [:mock]) | @setups.keys
39
+ end
40
+
41
+ private
42
+
43
+ # @rbs setup: Riffer::Rig::ProviderSetup | Hash[Symbol, untyped]
44
+ # @rbs return: Riffer::Rig::ProviderSetup
45
+ def normalize(setup)
46
+ setup.is_a?(::Riffer::Rig::ProviderSetup) ? setup : ::Riffer::Rig::ProviderSetup.from(setup)
47
+ end
48
+ end
@@ -88,6 +88,14 @@ class Riffer::Rig::Registrar
88
88
  @mcp_servers[name] = Riffer::Rig::Mcp::Declaration.new(url: url, headers: headers)
89
89
  end
90
90
 
91
+ # @rbs prefix: String | Symbol
92
+ # @rbs setup: Riffer::Rig::ProviderSetup | Hash[Symbol, untyped]?
93
+ # @rbs &block: () -> singleton(::Riffer::Providers::Base)
94
+ # @rbs return: void
95
+ def provider(prefix, setup: nil, &)
96
+ Riffer::Rig::Providers.register(prefix, setup:, &)
97
+ end
98
+
91
99
  # @rbs event: Symbol
92
100
  # @rbs &block: (Riffer::Rig::Events::_Event | ::Riffer::StreamEvents::Base) -> untyped
93
101
  # @rbs return: void