riffer-rig 0.8.0 → 0.10.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +13 -2
  4. data/lib/riffer/rig/cli/flags.rb +28 -2
  5. data/lib/riffer/rig/cli.rb +39 -7
  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 +383 -32
  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 +124 -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 +128 -0
  20. data/lib/riffer/rig/stores/recorder.rb +124 -0
  21. data/lib/riffer/rig/version.rb +1 -1
  22. data/lib/riffer/rig.rb +7 -1
  23. data/sig/generated/riffer/rig/cli/flags.rbs +11 -2
  24. data/sig/generated/riffer/rig/cli.rbs +15 -0
  25. data/sig/generated/riffer/rig/commands/auth.rbs +35 -0
  26. data/sig/generated/riffer/rig/commands/model.rbs +21 -9
  27. data/sig/generated/riffer/rig/commands/reload.rbs +12 -0
  28. data/sig/generated/riffer/rig/credentials.rbs +8 -0
  29. data/sig/generated/riffer/rig/extension.rbs +4 -0
  30. data/sig/generated/riffer/rig/loader.rbs +167 -8
  31. data/sig/generated/riffer/rig/provider_setup.rbs +15 -4
  32. data/sig/generated/riffer/rig/providers.rbs +28 -0
  33. data/sig/generated/riffer/rig/registrar.rbs +6 -0
  34. data/sig/generated/riffer/rig/runtime.rbs +73 -10
  35. data/sig/generated/riffer/rig/sdk.rbs +33 -0
  36. data/sig/generated/riffer/rig/settings/document.rbs +7 -1
  37. data/sig/generated/riffer/rig/settings.rbs +2 -3
  38. data/sig/generated/riffer/rig/stores/jsonl.rbs +69 -0
  39. data/sig/generated/riffer/rig/stores/recorder.rbs +72 -0
  40. data/sig/generated/riffer/rig.rbs +4 -0
  41. data/sig/manual/riffer/rig/commands/auth.rbs +6 -0
  42. data/sig/manual/riffer/rig/commands/reload.rbs +6 -0
  43. data/sig/manual/riffer/rig/providers.rbs +6 -0
  44. data/sig/manual/riffer/rig/sdk.rbs +6 -0
  45. data/sig/manual/riffer/rig/stores.rbs +12 -0
  46. metadata +18 -29
@@ -3,24 +3,25 @@
3
3
  class Riffer::Rig::Loader
4
4
  class ConfigurationError < StandardError; end
5
5
 
6
- PROVIDER_LIST = Riffer::Rig::Settings::PROVIDERS.join(', ') #: String
7
-
8
- ONBOARDING_QUESTION = 'Which model should riffer use? Enter provider/name, ' \
9
- "with a provider from: #{PROVIDER_LIST}".freeze #: String
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
6
+ # Raised by a strict load: the reload abandons instead of continuing.
7
+ class AbandonedError < StandardError; end
13
8
 
14
9
  EXTENSION_FEATURE = 'riffer/rig/extension' #: String
15
10
 
16
- private_constant :EXTENSION_FEATURE, :PROVIDER_LIST, :ONBOARDING_QUESTION, :NO_MODEL
11
+ private_constant :EXTENSION_FEATURE
17
12
 
18
13
  # @rbs @cwd: String
19
14
  # @rbs @host: Riffer::Rig::Hosts::_Host
20
15
  # @rbs @env: Riffer::Rig::Env
21
16
  # @rbs @home: String
22
17
  # @rbs @riffer_config: Riffer::Config
18
+ # @rbs @store: Riffer::Rig::Stores::_Store | nil
23
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
+ # @rbs @reload_mode: (:auto | :manual | nil)
24
25
 
25
26
  # @dynamic tracked_files
26
27
  attr_reader :tracked_files #: Array[String]
@@ -36,6 +37,9 @@ class Riffer::Rig::Loader
36
37
  # @rbs agents_md: bool
37
38
  # @rbs tools: Array[String]?
38
39
  # @rbs max_steps: Integer?
40
+ # @rbs store: Riffer::Rig::Stores::_Store | nil
41
+ # @rbs reload: (:auto | :manual | nil)
42
+ # @rbs snapshot: Hash[Symbol, untyped]?
39
43
  # @rbs return: Riffer::Rig::Runtime
40
44
  def self.runtime(
41
45
  cwd:,
@@ -48,9 +52,21 @@ class Riffer::Rig::Loader
48
52
  skills: true,
49
53
  agents_md: true,
50
54
  tools: nil,
51
- max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS
55
+ max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS,
56
+ store: Riffer::Rig::Stores::JSONL.new,
57
+ reload: nil,
58
+ snapshot: nil
52
59
  )
53
- new(cwd:, host:, env:, home:, riffer_config:).runtime(model:, extensions:, skills:, agents_md:, tools:, max_steps:)
60
+ new(cwd:, host:, env:, home:, riffer_config:, store:).runtime(
61
+ model:,
62
+ extensions:,
63
+ skills:,
64
+ agents_md:,
65
+ tools:,
66
+ max_steps:,
67
+ reload:,
68
+ snapshot:
69
+ )
54
70
  end
55
71
 
56
72
  # @rbs cwd: String
@@ -58,8 +74,16 @@ class Riffer::Rig::Loader
58
74
  # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
59
75
  # @rbs home: String
60
76
  # @rbs riffer_config: Riffer::Config
77
+ # @rbs store: Riffer::Rig::Stores::_Store | nil
61
78
  # @rbs return: void
62
- def initialize(cwd:, host:, env: Riffer::Rig::Env.load, home: Dir.home, riffer_config: Riffer.config)
79
+ def initialize(
80
+ cwd:,
81
+ host:,
82
+ store: Riffer::Rig::Stores::JSONL.new,
83
+ env: Riffer::Rig::Env.load,
84
+ home: Dir.home,
85
+ riffer_config: Riffer.config
86
+ )
63
87
  raise ConfigurationError, env.message if env.is_a?(Riffer::Rig::Env::Invalid)
64
88
 
65
89
  @cwd = cwd
@@ -67,7 +91,13 @@ class Riffer::Rig::Loader
67
91
  @env = env
68
92
  @home = home
69
93
  @riffer_config = riffer_config
94
+ @store = store
70
95
  @tracked_files = []
96
+ @stripped = []
97
+ @extensions_enabled = true
98
+ @gem_extensions = []
99
+ @file_state = nil
100
+ @reload_mode = nil
71
101
  end
72
102
 
73
103
  # @rbs model: String?
@@ -76,6 +106,8 @@ class Riffer::Rig::Loader
76
106
  # @rbs agents_md: bool
77
107
  # @rbs tools: Array[String]?
78
108
  # @rbs max_steps: Integer?
109
+ # @rbs reload: (:auto | :manual | nil)
110
+ # @rbs snapshot: Hash[Symbol, untyped]?
79
111
  # @rbs return: Riffer::Rig::Runtime
80
112
  def runtime(
81
113
  model: nil,
@@ -83,21 +115,28 @@ class Riffer::Rig::Loader
83
115
  skills: true,
84
116
  agents_md: true,
85
117
  tools: nil,
86
- max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS
118
+ max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS,
119
+ reload: nil,
120
+ snapshot: nil
87
121
  )
88
- settings = Riffer::Rig::Settings.merge(
89
- Riffer::Rig::Settings.read(home_settings_path),
90
- Riffer::Rig::Settings.read(File.join(@cwd, '.riffer', 'settings.json'))
91
- )
122
+ settings = merged_settings
92
123
  document = Riffer::Rig::Settings::Document.new(settings)
93
124
  selected = select_model(model || @env.model || document.model)
94
125
  provider = Riffer::Rig::Settings.provider_for(selected).to_s
126
+ ensure_sdk(provider)
95
127
  credentials = { provider.to_sym => credentials_for(provider) }
96
- stripped = { skills: skills, agents_md: agents_md }.reject { |_name, kept| kept }.keys
97
- loaded = Riffer::Rig::Bundled::BY_NAME.except(*document.disabled.map(&:to_sym), *stripped).values
128
+ @extensions_enabled = extensions
129
+ @reload_mode = reload
130
+ @stripped = { skills: skills, agents_md: agents_md }.reject { |_name, kept| kept }.keys
131
+ loaded = Riffer::Rig::Bundled::BY_NAME.except(*document.disabled.map(&:to_sym), *@stripped).values
132
+ @tracked_files = []
133
+ @gem_extensions = []
98
134
  loaded += load_rig_files(document.autoload) if extensions
99
135
 
100
- Riffer::Rig::Runtime.new(
136
+ recorder = recorder_for(document, resumed: !snapshot.nil?)
137
+ loaded << Riffer::Rig::Stores::Recorder.extension(recorder) if recorder
138
+
139
+ built = Riffer::Rig::Runtime.new(
101
140
  selected,
102
141
  extensions: loaded,
103
142
  tools: tools,
@@ -108,20 +147,271 @@ class Riffer::Rig::Loader
108
147
  credentials: credentials,
109
148
  pricing: document.models,
110
149
  riffer_config: @riffer_config,
111
- model_options: Riffer::Rig::Settings.model_options(selected, document.reasoning)
150
+ model_options: Riffer::Rig::Settings.model_options(selected, document.reasoning),
151
+ native_tools: document.native_tools,
152
+ snapshot: snapshot
112
153
  )
154
+ built.install_command(Riffer::Rig::Commands::Reload.command(self))
155
+ built.install_reload_check(->(runtime) { automatic_reload(runtime) }) unless reload_mode(document) == :manual
156
+ @file_state = file_state
157
+ recorder&.attach(built)
158
+ built
159
+ end
160
+
161
+ # @rbs model: String?
162
+ # @rbs extensions: bool
163
+ # @rbs skills: bool
164
+ # @rbs agents_md: bool
165
+ # @rbs tools: Array[String]?
166
+ # @rbs max_steps: Integer?
167
+ # @rbs return: Riffer::Rig::Runtime?
168
+ def continue(
169
+ model: nil,
170
+ extensions: true,
171
+ skills: true,
172
+ agents_md: true,
173
+ tools: nil,
174
+ max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS
175
+ )
176
+ store = @store
177
+ return nil unless store
178
+
179
+ latest = store.list(cwd: @cwd).max_by { |header| store.updated(header.fetch(:id)) || Time.at(0) }
180
+ return nil unless latest
181
+
182
+ id = latest.fetch(:id) #: String
183
+ resume(id, model:, extensions:, skills:, agents_md:, tools:, max_steps:)
184
+ end
185
+
186
+ # A resume carries the history over and nothing else: the settings,
187
+ # credentials, extensions and tools are today's.
188
+ # @rbs id: String
189
+ # @rbs model: String?
190
+ # @rbs extensions: bool
191
+ # @rbs skills: bool
192
+ # @rbs agents_md: bool
193
+ # @rbs tools: Array[String]?
194
+ # @rbs max_steps: Integer?
195
+ # @rbs return: Riffer::Rig::Runtime?
196
+ def resume(
197
+ id,
198
+ model: nil,
199
+ extensions: true,
200
+ skills: true,
201
+ agents_md: true,
202
+ tools: nil,
203
+ max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS
204
+ )
205
+ store = @store
206
+ return nil unless store
207
+
208
+ entries = store.read(id)
209
+ return nil if entries.empty?
210
+
211
+ runtime(model:, extensions:, skills:, agents_md:, tools:, max_steps:, snapshot: snapshot_of(entries, id))
212
+ end
213
+
214
+ # @rbs all: bool
215
+ # @rbs return: Array[Hash[Symbol, untyped]]
216
+ def list(all: false)
217
+ store = @store
218
+ return [] unless store
219
+
220
+ all ? store.list : store.list(cwd: @cwd)
221
+ end
222
+
223
+ # @rbs id: String
224
+ # @rbs return: void
225
+ def delete(id)
226
+ store = @store
227
+ return unless store
228
+
229
+ store.delete(id)
230
+ end
231
+
232
+ # Without force, a no-op while the tracked file set and the two rig.rb paths
233
+ # look as the last discovery left them and the reload mode is not manual —
234
+ # the entry point the automatic trigger (#133) calls. The snapshot advances
235
+ # on every attempt, failed ones included, so a failure does not retry every
236
+ # request. Failures are notified once and reported as the message, the way
237
+ # Settings.rejection reports a rejection; the Runtime is the success value.
238
+ # @rbs runtime: Riffer::Rig::Runtime
239
+ # @rbs force: bool
240
+ # @rbs return: (Riffer::Rig::Runtime | String)
241
+ def reload(runtime, force: false)
242
+ return runtime if !force && file_state == @file_state
243
+ return runtime if !force && manual_reload?
244
+
245
+ outcome = perform_reload(runtime)
246
+ @file_state = file_state
247
+ outcome
113
248
  end
114
249
 
115
250
  private
116
251
 
252
+ # The reload mode a build runs under: the keyword wins over the setting,
253
+ # the setting over the default.
254
+ # @rbs document: Riffer::Rig::Settings::Document
255
+ # @rbs return: (:auto | :manual)
256
+ def reload_mode(document)
257
+ @reload_mode || document.reload
258
+ end
259
+
260
+ # @rbs return: bool
261
+ def manual_reload?
262
+ reload_mode(Riffer::Rig::Settings::Document.new(merged_settings)) == :manual
263
+ end
264
+
265
+ # The automatic trigger: runs at every before_request boundary, so the
266
+ # change check decides and a no-op costs the stat alone.
267
+ # @rbs runtime: Riffer::Rig::Runtime
268
+ # @rbs return: void
269
+ def automatic_reload(runtime)
270
+ _ = reload(runtime)
271
+ end
272
+
273
+ # @rbs return: Hash[Symbol, untyped]
274
+ def merged_settings
275
+ Riffer::Rig::Settings.merge(
276
+ Riffer::Rig::Settings.read(home_settings_path),
277
+ Riffer::Rig::Settings.read(File.join(@cwd, '.riffer', 'settings.json'))
278
+ )
279
+ end
280
+
281
+ # Notifies every failure once and reports its message, unexpected raises
282
+ # included — the automatic check runs mid-turn with no other isolation, and a
283
+ # raised attempt must still advance the snapshot in reload. A rebuilt Runtime
284
+ # is the success value.
285
+ # @rbs runtime: Riffer::Rig::Runtime
286
+ # @rbs return: (Riffer::Rig::Runtime | String)
287
+ def perform_reload(runtime)
288
+ settings = merged_settings
289
+ document = Riffer::Rig::Settings::Document.new(settings)
290
+ extensions = reload_extensions(document, settings)
291
+
292
+ failure = reload_credentials(runtime)
293
+ return notify_failure(failure) if failure
294
+
295
+ runtime.rebuild(extensions: validated(extensions, settings), settings: settings)
296
+ runtime
297
+ rescue StandardError => e
298
+ notify_failure(e.message)
299
+ end
300
+
301
+ # @rbs message: String
302
+ # @rbs return: String
303
+ def notify_failure(message)
304
+ @host.notify(message, level: :error)
305
+ message
306
+ end
307
+
308
+ # @rbs document: Riffer::Rig::Settings::Document
309
+ # @rbs settings: Hash[Symbol, untyped]
310
+ # @rbs return: Array[Riffer::Rig::Extension]
311
+ def reload_extensions(document, settings)
312
+ candidates = Riffer::Rig::Bundled::BY_NAME.except(*document.disabled.map(&:to_sym), *@stripped).values
313
+ return validated(candidates, settings) unless @extensions_enabled
314
+
315
+ scrub_tracked_features
316
+ @tracked_files = []
317
+ candidates.concat(@gem_extensions, rig_recordings)
318
+ validated(candidates, settings)
319
+ end
320
+
321
+ # @rbs return: Array[Riffer::Rig::Extension]
322
+ def rig_recordings
323
+ recorded = [] #: Array[Riffer::Rig::Extension]
324
+ recorded.concat(load_rig_file(home_rig_path, confirm_trust: false))
325
+ recorded.concat(load_rig_file(project_rig_path, confirm_trust: true))
326
+ recorded
327
+ end
328
+
329
+ # @rbs path: String
330
+ # @rbs confirm_trust: bool
331
+ # @rbs return: Array[Riffer::Rig::Extension]
332
+ def load_rig_file(path, confirm_trust:)
333
+ return [] unless File.file?(path)
334
+ return [] if confirm_trust && !trusted?(path)
335
+
336
+ load_file(path, track: true, strict: true)
337
+ end
338
+
339
+ # @rbs return: void
340
+ def scrub_tracked_features
341
+ @tracked_files.each { |path| $LOADED_FEATURES.delete(path) }
342
+ end
343
+
344
+ # Runs each extension against a throwaway registrar so that a failing block
345
+ # is skipped and reported instead of aborting the rebuild.
346
+ # @rbs extensions: Array[Riffer::Rig::Extension]
347
+ # @rbs settings: Hash[Symbol, untyped]
348
+ # @rbs return: Array[Riffer::Rig::Extension]
349
+ def validated(extensions, settings)
350
+ extensions.select do |extension|
351
+ registrar = Riffer::Rig::Registrar.new(extension.name, settings[extension.name.to_sym] || {})
352
+ error = extension.load_into(registrar)
353
+ next true unless error
354
+
355
+ @host.notify("Extension #{extension.name} failed to load: #{error.message}", level: :error)
356
+ false
357
+ end
358
+ end
359
+
360
+ # @rbs runtime: Riffer::Rig::Runtime
361
+ # @rbs return: String?
362
+ def reload_credentials(runtime)
363
+ provider = runtime.model.partition('/').first
364
+ resolution = Riffer::Rig::Credentials.resolve(
365
+ provider,
366
+ host: asking_host,
367
+ env: @env,
368
+ auth_path: File.join(@home, '.riffer', 'auth.json'),
369
+ settings_path: home_settings_path
370
+ )
371
+ return missing_credentials(provider, resolution.missing) unless resolution.missing.empty?
372
+
373
+ Riffer::Rig::Credentials.apply(provider, resolution.values, config: @riffer_config)
374
+ runtime.merge_credentials(provider.to_sym, resolution.values)
375
+ nil
376
+ end
377
+
378
+ # @rbs return: Hash[String, Time?]
379
+ def file_state
380
+ (@tracked_files + rig_paths).uniq.to_h { |path| [path, stamp(path)] }
381
+ end
382
+
383
+ # @rbs return: Array[String]
384
+ def rig_paths
385
+ return [] unless @extensions_enabled
386
+
387
+ [home_rig_path, project_rig_path]
388
+ end
389
+
390
+ # @rbs path: String
391
+ # @rbs return: Time?
392
+ def stamp(path)
393
+ File.file?(path) ? File.mtime(path) : nil
394
+ end
395
+
396
+ # @rbs return: String
397
+ def home_rig_path
398
+ File.expand_path(File.join(@home, '.riffer', 'rig.rb'))
399
+ end
400
+
401
+ # @rbs return: String
402
+ def project_rig_path
403
+ File.expand_path(File.join(@cwd, '.riffer', 'rig.rb'))
404
+ end
405
+
117
406
  # @rbs autoload: bool
118
407
  # @rbs return: Array[Riffer::Rig::Extension]
119
408
  def load_rig_files(autoload)
120
- recorded = gem_extension_files(autoload).flat_map { |file| load_file(File.expand_path(file), track: false) }
121
- home = File.expand_path(File.join(@home, '.riffer', 'rig.rb'))
122
- recorded.concat(load_file(home, track: true)) if File.file?(home)
123
- project = File.expand_path(File.join(@cwd, '.riffer', 'rig.rb'))
124
- recorded.concat(load_file(project, track: true)) if File.file?(project) && trusted?(project)
409
+ @gem_extensions = gem_extension_files(autoload).flat_map { |file| load_file(File.expand_path(file), track: false) }
410
+ recorded = @gem_extensions.dup
411
+ recorded.concat(load_file(home_rig_path, track: true)) if File.file?(home_rig_path)
412
+ if File.file?(project_rig_path) && trusted?(project_rig_path)
413
+ recorded.concat(load_file(project_rig_path, track: true))
414
+ end
125
415
  recorded
126
416
  end
127
417
 
@@ -150,17 +440,25 @@ class Riffer::Rig::Loader
150
440
  File.join(@home, '.riffer', 'trust.json')
151
441
  end
152
442
 
443
+ # Loads one file and returns the extensions it recorded. A tracked load adds
444
+ # the features the load added to the tracked file set. A failure is reported
445
+ # to the host and yields nothing, unless strict: then it propagates as
446
+ # AbandonedError for the reload to abandon on.
153
447
  # @rbs path: String
154
448
  # @rbs track: bool
449
+ # @rbs strict: bool
155
450
  # @rbs return: Array[Riffer::Rig::Extension]
156
- def load_file(path, track:)
451
+ def load_file(path, track:, strict: false)
157
452
  before = Riffer::Rig.extensions
158
453
  before_features = $LOADED_FEATURES.dup
159
454
  begin
160
455
  load path
161
456
  rescue StandardError => e
162
457
  track_loaded(before_features, path) if track
163
- @host.notify("#{path} failed to load: #{e.message}", level: :error)
458
+ message = "#{path} failed to load: #{e.message}"
459
+ raise AbandonedError, message if strict
460
+
461
+ @host.notify(message, level: :error)
164
462
  return []
165
463
  end
166
464
  track_loaded(before_features, path) if track
@@ -195,12 +493,40 @@ class Riffer::Rig::Loader
195
493
 
196
494
  # @rbs return: String
197
495
  def ask_for_model
198
- answer = asking_host.ask(ONBOARDING_QUESTION).to_s.strip
199
- raise ConfigurationError, NO_MODEL if answer.empty?
496
+ answer = asking_host.ask(onboarding_question).to_s.strip
497
+ raise ConfigurationError, no_model if answer.empty?
200
498
 
201
499
  answer
202
500
  end
203
501
 
502
+ # @rbs return: String
503
+ def onboarding_question
504
+ 'Which model should riffer use? Enter provider/name, ' \
505
+ "with a provider from: #{provider_list}"
506
+ end
507
+
508
+ # @rbs return: String
509
+ def no_model
510
+ 'No model is set: pass --model provider/name, set RIFFER_MODEL, or set "model" in ' \
511
+ "~/.riffer/settings.json, with a provider from: #{provider_list}"
512
+ end
513
+
514
+ # @rbs return: String
515
+ def provider_list
516
+ Riffer::Rig::Settings.providers.join(', ')
517
+ end
518
+
519
+ # @rbs provider: String
520
+ # @rbs return: void
521
+ def ensure_sdk(provider)
522
+ sdk = Riffer::Rig::ProviderSetup.for(provider).sdk
523
+ return unless sdk
524
+
525
+ gem, requirement = sdk
526
+ message = Riffer::Rig::SDK.ensure(gem, requirement, host: asking_host)
527
+ raise ConfigurationError, message if message
528
+ end
529
+
204
530
  # @rbs provider: String
205
531
  # @rbs return: Hash[Symbol, String]
206
532
  def credentials_for(provider)
@@ -222,9 +548,8 @@ class Riffer::Rig::Loader
222
548
  # @rbs return: String
223
549
  def missing_credentials(provider, missing)
224
550
  setup = Riffer::Rig::ProviderSetup.for(provider)
225
- fields = setup.fields.select { |field| missing.include?(field.name) }
226
- wanted = fields.map { |field| "#{field.name} (#{field.env.join(' or ')})" }.join(', ')
227
- "#{provider} has no #{wanted}; set it in the environment or run riffer interactively to paste it" \
551
+ "#{provider} has no #{setup.missing_fields(missing)}; " \
552
+ 'set it in the environment or run riffer interactively to paste it' \
228
553
  "#{" (create one at #{setup.url})" if setup.url}"
229
554
  end
230
555
 
@@ -237,4 +562,30 @@ class Riffer::Rig::Loader
237
562
  def home_settings_path
238
563
  File.join(@home, '.riffer', 'settings.json')
239
564
  end
565
+
566
+ # @rbs entries: Array[Hash[Symbol, untyped]]
567
+ # @rbs id: String
568
+ # @rbs return: Hash[Symbol, untyped]
569
+ def snapshot_of(entries, id)
570
+ models = entries.select { |entry| entry[:type] == 'model' }
571
+ {
572
+ id: id,
573
+ messages: entries.select { |entry| entry[:type] == 'message' }.map { |entry| entry.fetch(:message) },
574
+ model: models.last&.fetch(:model),
575
+ skills: entries.select { |entry| entry[:type] == 'skill' }.map { |entry| entry.fetch(:skill) }
576
+ }
577
+ end
578
+
579
+ # The store records unless it was declined with store: nil or globally with
580
+ # "sessions": {"save": false}.
581
+ # @rbs document: Riffer::Rig::Settings::Document
582
+ # @rbs resumed: bool
583
+ # @rbs return: Riffer::Rig::Stores::Recorder?
584
+ def recorder_for(document, resumed:)
585
+ store = @store
586
+ return nil unless document.save
587
+ return nil unless store
588
+
589
+ Riffer::Rig::Stores::Recorder.new(store: store, resumed: resumed)
590
+ end
240
591
  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