riffer 0.40.0 → 0.41.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/providers.md +10 -1
  3. data/.agents/rbs-inline.md +2 -2
  4. data/.release-please-manifest.json +1 -1
  5. data/CHANGELOG.md +11 -0
  6. data/README.md +20 -24
  7. data/docs/AGENTS.md +0 -11
  8. data/docs/CONFIGURATION.md +105 -21
  9. data/docs/SERIALIZATION.md +4 -3
  10. data/docs/providers/AMAZON_BEDROCK.md +14 -8
  11. data/docs/providers/ANTHROPIC.md +9 -7
  12. data/docs/providers/AZURE_OPENAI.md +12 -12
  13. data/docs/providers/CUSTOM_PROVIDERS.md +32 -16
  14. data/docs/providers/GEMINI.md +28 -5
  15. data/docs/providers/OPENAI.md +21 -5
  16. data/docs/providers/OPENROUTER.md +11 -6
  17. data/docs/providers/PROVIDERS.md +16 -1
  18. data/lib/riffer/agent/config.rb +0 -6
  19. data/lib/riffer/agent/serializer.rb +0 -2
  20. data/lib/riffer/agent.rb +1 -9
  21. data/lib/riffer/config.rb +6 -6
  22. data/lib/riffer/evals/judge.rb +3 -5
  23. data/lib/riffer/providers/amazon_bedrock.rb +31 -19
  24. data/lib/riffer/providers/anthropic.rb +19 -8
  25. data/lib/riffer/providers/azure_open_ai.rb +19 -11
  26. data/lib/riffer/providers/base.rb +28 -0
  27. data/lib/riffer/providers/gemini/client.rb +120 -0
  28. data/lib/riffer/providers/gemini.rb +13 -62
  29. data/lib/riffer/providers/mock.rb +5 -4
  30. data/lib/riffer/providers/open_ai.rb +23 -8
  31. data/lib/riffer/providers/open_router.rb +23 -9
  32. data/lib/riffer/version.rb +1 -1
  33. data/sig/_private/riffer/providers/amazon_bedrock.rbs +4 -2
  34. data/sig/_private/riffer/providers/anthropic.rbs +4 -2
  35. data/sig/_private/riffer/providers/gemini.rbs +7 -0
  36. data/sig/_private/riffer/providers/open_ai.rbs +4 -2
  37. data/sig/_private/riffer/providers/open_router.rbs +4 -2
  38. data/sig/generated/riffer/agent/config.rbs +1 -5
  39. data/sig/generated/riffer/agent.rbs +0 -6
  40. data/sig/generated/riffer/config.rbs +25 -15
  41. data/sig/generated/riffer/evals/judge.rbs +2 -4
  42. data/sig/generated/riffer/providers/amazon_bedrock.rbs +13 -2
  43. data/sig/generated/riffer/providers/anthropic.rbs +13 -2
  44. data/sig/generated/riffer/providers/azure_open_ai.rbs +14 -4
  45. data/sig/generated/riffer/providers/base.rbs +20 -0
  46. data/sig/generated/riffer/providers/gemini/client.rbs +65 -0
  47. data/sig/generated/riffer/providers/gemini.rbs +7 -23
  48. data/sig/generated/riffer/providers/mock.rbs +4 -3
  49. data/sig/generated/riffer/providers/open_ai.rbs +13 -2
  50. data/sig/generated/riffer/providers/open_router.rbs +16 -3
  51. metadata +4 -1
@@ -12,15 +12,38 @@ Riffer.configure do |config|
12
12
  end
13
13
  ```
14
14
 
15
- Or per-agent:
15
+ ## HTTP Client
16
+
17
+ Gemini has no vendor SDK, so riffer ships its own transport: `Riffer::Providers::Gemini::Client`. The provider builds one from the configured `api_key` by default; construct your own to tune the HTTP knobs and assign it to `config.gemini.client`:
16
18
 
17
19
  ```ruby
18
- class MyAgent < Riffer::Agent
19
- model 'gemini/gemini-2.5-flash-lite'
20
- provider_options api_key: ENV['GEMINI_API_KEY']
20
+ Riffer.configure do |config|
21
+ config.gemini.client = Riffer::Providers::Gemini::Client.new(
22
+ api_key: ENV['GEMINI_API_KEY'],
23
+ read_timeout: 120
24
+ )
21
25
  end
22
26
  ```
23
27
 
28
+ | Option | Default | Description |
29
+ | --------------- | ------------------------------------------- | -------------------------------------- |
30
+ | `api_key` | `nil` | Sent as the `x-goog-api-key` header |
31
+ | `base_url` | `https://generativelanguage.googleapis.com` | API origin (proxies, regional mirrors) |
32
+ | `open_timeout` | `10` | Connection-open timeout in seconds |
33
+ | `read_timeout` | `60` | Read timeout in seconds |
34
+ | `write_timeout` | `nil` | Write timeout in seconds |
35
+ | `proxy_address` | `nil` | HTTP proxy host |
36
+ | `proxy_port` | `nil` | HTTP proxy port |
37
+
38
+ The setting accepts a client instance or a no-argument `Proc`, resolved on every LLM call — see [Configuration → Provider Clients](../CONFIGURATION.md#provider-clients).
39
+
40
+ The class is a default implementation, not a required base: any object implementing the two-method contract works, e.g. a Faraday-based or instrumented transport.
41
+
42
+ | Method | Contract |
43
+ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
44
+ | `post(path, body)` | POST `body` as JSON to `path`; return the parsed response `Hash` (symbol keys); raise `Riffer::Error` on a non-success status |
45
+ | `post_stream(path, body) { \|chunk\| }` | POST `body` as JSON to `path`; yield raw response body chunks; raise `Riffer::Error` on a non-success status |
46
+
24
47
  ## Supported Models
25
48
 
26
49
  Use Gemini model IDs in the `gemini/model` format:
@@ -62,7 +85,7 @@ model_options topP: 0.9
62
85
  ### Basic Generation
63
86
 
64
87
  ```ruby
65
- provider = Riffer::Providers::Gemini.new(api_key: ENV['GEMINI_API_KEY'])
88
+ provider = Riffer::Providers::Gemini.new
66
89
 
67
90
  response = provider.generate_text(
68
91
  prompt: "Hello!",
@@ -20,12 +20,28 @@ Riffer.configure do |config|
20
20
  end
21
21
  ```
22
22
 
23
- Or per-agent:
23
+ Both `api_key` and `base_url` resolve in order: `Riffer.config.openai.*` → the OpenAI SDK's own `OPENAI_API_KEY` / `OPENAI_BASE_URL` lookup. Leaving one unset in riffer means the SDK resolves it, so an `OPENAI_BASE_URL` gateway is honored without any riffer configuration.
24
+
25
+ For anything beyond the API key — timeouts, retries, proxies — supply your own `OpenAI::Client`:
24
26
 
25
27
  ```ruby
26
- class MyAgent < Riffer::Agent
27
- model 'openai/gpt-5-mini'
28
- provider_options api_key: ENV['CUSTOM_API_KEY']
28
+ Riffer.configure do |config|
29
+ config.openai.client = OpenAI::Client.new(
30
+ api_key: ENV['OPENAI_API_KEY'],
31
+ timeout: 30,
32
+ max_retries: 4
33
+ )
34
+ end
35
+ ```
36
+
37
+ The setting accepts a client instance or a no-argument `Proc`, resolved on every LLM call — see [Configuration → Provider Clients](../CONFIGURATION.md#provider-clients).
38
+
39
+ For OpenAI-compatible servers (LiteLLM, vLLM, corporate gateways), configure a `base_url`:
40
+
41
+ ```ruby
42
+ Riffer.configure do |config|
43
+ config.openai.api_key = ENV['GATEWAY_KEY']
44
+ config.openai.base_url = 'http://localhost:4000/v1'
29
45
  end
30
46
  ```
31
47
 
@@ -185,7 +201,7 @@ The provider converts Riffer messages to OpenAI format:
185
201
  ## Direct Provider Usage
186
202
 
187
203
  ```ruby
188
- provider = Riffer::Providers::OpenAI.new(api_key: ENV['OPENAI_API_KEY'])
204
+ provider = Riffer::Providers::OpenAI.new
189
205
 
190
206
  response = provider.generate_text(
191
207
  prompt: "Hello!",
@@ -24,16 +24,21 @@ Riffer.configure do |config|
24
24
  end
25
25
  ```
26
26
 
27
- Or per-agent:
27
+ The `api_key` resolves in order: `Riffer.config.openrouter.api_key` → `ENV['OPENROUTER_API_KEY']`.
28
+
29
+ For anything beyond the API key — timeouts, retries, proxies — supply your own `OpenAI::Client` pinned to the OpenRouter endpoint:
28
30
 
29
31
  ```ruby
30
- class MyAgent < Riffer::Agent
31
- model 'openrouter/anthropic/claude-sonnet-4.6'
32
- provider_options api_key: ENV['MY_OR_KEY']
32
+ Riffer.configure do |config|
33
+ config.openrouter.client = OpenAI::Client.new(
34
+ api_key: ENV['OPENROUTER_API_KEY'],
35
+ base_url: 'https://openrouter.ai/api/v1',
36
+ timeout: 60
37
+ )
33
38
  end
34
39
  ```
35
40
 
36
- The `api_key` resolves in order: keyword arg → `Riffer.config.openrouter.api_key` → `ENV['OPENROUTER_API_KEY']`.
41
+ The setting accepts a client instance or a no-argument `Proc`, resolved on every LLM call — see [Configuration → Provider Clients](../CONFIGURATION.md#provider-clients).
37
42
 
38
43
  ## Supported Models
39
44
 
@@ -229,7 +234,7 @@ User messages with files become multi-part content (`image_url` for images, `fil
229
234
  ## Direct Provider Usage
230
235
 
231
236
  ```ruby
232
- provider = Riffer::Providers::OpenRouter.new(api_key: ENV['OPENROUTER_API_KEY'])
237
+ provider = Riffer::Providers::OpenRouter.new
233
238
 
234
239
  response = provider.generate_text(
235
240
  prompt: 'Hello!',
@@ -30,6 +30,21 @@ class MyAgent < Riffer::Agent
30
30
  end
31
31
  ```
32
32
 
33
+ ## Credentials and Clients
34
+
35
+ Every credential comes from configuration: `Riffer::Providers::OpenAI.new` takes no arguments.
36
+
37
+ | Provider | Configured credentials |
38
+ | -------------- | ----------------------------------------------------------------- |
39
+ | OpenAI | `config.openai.api_key`, `config.openai.base_url` |
40
+ | Azure OpenAI | `config.azure_openai.api_key`, `config.azure_openai.endpoint` |
41
+ | Anthropic | `config.anthropic.api_key` |
42
+ | Amazon Bedrock | `config.amazon_bedrock.api_token`, `config.amazon_bedrock.region` |
43
+ | Gemini | `config.gemini.api_key` |
44
+ | OpenRouter | `config.openrouter.api_key` |
45
+
46
+ Out of the box, each provider builds an SDK client from these credentials. Everything else — timeouts, retries, proxies, custom auth — is configured by assigning your own client (an instance, or a `Proc` resolved on every LLM call) to `Riffer.config.<provider>.client`. See [Configuration → Provider Clients](../CONFIGURATION.md#provider-clients).
47
+
33
48
  ## Provider Interface
34
49
 
35
50
  All providers inherit from `Riffer::Providers::Base` and implement:
@@ -39,7 +54,7 @@ All providers inherit from `Riffer::Providers::Base` and implement:
39
54
  Generates a response synchronously:
40
55
 
41
56
  ```ruby
42
- provider = Riffer::Providers::OpenAI.new(api_key: "...")
57
+ provider = Riffer::Providers::OpenAI.new
43
58
 
44
59
  response = provider.generate_text(
45
60
  prompt: "Hello!",
@@ -16,9 +16,6 @@ class Riffer::Agent::Config
16
16
  # The configured instructions.
17
17
  attr_reader :instructions #: (String | Proc)?
18
18
 
19
- # Options passed to the provider client.
20
- attr_accessor :provider_options #: Hash[Symbol, untyped]
21
-
22
19
  # Options passed to generate_text/stream_text.
23
20
  attr_accessor :model_options #: Hash[Symbol, untyped]
24
21
 
@@ -50,7 +47,6 @@ class Riffer::Agent::Config
50
47
  # ?identifier: String?,
51
48
  # ?model: (String | Proc)?,
52
49
  # ?instructions: (String | Proc)?,
53
- # ?provider_options: Hash[Symbol, untyped],
54
50
  # ?model_options: Hash[Symbol, untyped],
55
51
  # ?structured_output: Riffer::Params?,
56
52
  # ?max_steps: Numeric?,
@@ -64,7 +60,6 @@ class Riffer::Agent::Config
64
60
  identifier: nil,
65
61
  model: nil,
66
62
  instructions: nil,
67
- provider_options: {},
68
63
  model_options: {},
69
64
  structured_output: nil,
70
65
  max_steps: DEFAULT_MAX_STEPS,
@@ -74,7 +69,6 @@ class Riffer::Agent::Config
74
69
  skills_config: nil,
75
70
  guardrails: { before: [], after: [] }
76
71
  )
77
- @provider_options = provider_options
78
72
  @model_options = model_options
79
73
  @max_steps = max_steps
80
74
  @tools_config = tools_config
@@ -37,7 +37,6 @@ module Riffer::Agent::Serializer
37
37
  model: "#{agent.provider_name}/#{agent.model_name}",
38
38
  instructions: agent.instruction_message&.content,
39
39
  model_options: config.model_options,
40
- provider_options: config.provider_options,
41
40
  max_steps: encode_max_steps(config.max_steps),
42
41
  structured_output: config.structured_output&.to_json_schema(strict: false),
43
42
  tools: agent.tools.map { |tool_class| tool_descriptor(tool_class) },
@@ -96,7 +95,6 @@ module Riffer::Agent::Serializer
96
95
  identifier: hash[:identifier],
97
96
  model: hash[:model],
98
97
  instructions: hash[:instructions],
99
- provider_options: hash[:provider_options] || {},
100
98
  model_options: hash[:model_options] || {},
101
99
  structured_output: decode_structured_output(hash[:structured_output]),
102
100
  max_steps: decode_max_steps(hash),
data/lib/riffer/agent.rb CHANGED
@@ -57,14 +57,6 @@ class Riffer::Agent
57
57
  value.nil? ? config.instructions : (config.instructions = value)
58
58
  end
59
59
 
60
- # Gets or sets provider options passed to the provider client.
61
- #
62
- #--
63
- #: (?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
64
- def self.provider_options(options = nil)
65
- options.nil? ? config.provider_options : (config.provider_options = options)
66
- end
67
-
68
60
  # Gets or sets model options passed to generate_text/stream_text.
69
61
  #
70
62
  #--
@@ -408,7 +400,7 @@ class Riffer::Agent
408
400
  provider_class = Riffer::Providers::Repository.find(@provider_name)
409
401
  raise Riffer::ArgumentError, "Provider not found: #{@provider_name}" unless provider_class
410
402
 
411
- provider_class.new(**@config.provider_options)
403
+ provider_class.new
412
404
  end
413
405
 
414
406
  #--
data/lib/riffer/config.rb CHANGED
@@ -3,12 +3,12 @@
3
3
 
4
4
  # Configuration for the Riffer framework.
5
5
  class Riffer::Config
6
- AmazonBedrock = Struct.new(:api_token, :region)
7
- Anthropic = Struct.new(:api_key)
8
- AzureOpenAI = Struct.new(:api_key, :endpoint)
9
- Gemini = Struct.new(:api_key, :open_timeout, :read_timeout)
10
- OpenAI = Struct.new(:api_key)
11
- OpenRouter = Struct.new(:api_key)
6
+ AmazonBedrock = Struct.new(:api_token, :region, :client)
7
+ Anthropic = Struct.new(:api_key, :client)
8
+ AzureOpenAI = Struct.new(:api_key, :endpoint, :client)
9
+ Gemini = Struct.new(:api_key, :client)
10
+ OpenAI = Struct.new(:api_key, :base_url, :client)
11
+ OpenRouter = Struct.new(:api_key, :client)
12
12
  Evals = Struct.new(:judge_model)
13
13
  Mcp = Struct.new(:credentials, :discovery_runner)
14
14
 
@@ -6,7 +6,6 @@ require "json"
6
6
  # Executes LLM-as-judge evaluations, using tool calling internally to get
7
7
  # structured output from the judge model.
8
8
  class Riffer::Evals::Judge
9
- # @rbs @provider_options: Hash[Symbol, untyped]
10
9
  # @rbs @provider_instance: Riffer::Providers::Base?
11
10
  # @rbs @provider_name: String?
12
11
  # @rbs @model_name: String?
@@ -33,15 +32,14 @@ class Riffer::Evals::Judge
33
32
 
34
33
  # Raises Riffer::ArgumentError unless +model+ is "provider/model" format.
35
34
  #--
36
- #: (model: String, ?provider_options: Hash[Symbol, untyped]) -> void
37
- def initialize(model:, provider_options: {})
35
+ #: (model: String) -> void
36
+ def initialize(model:)
38
37
  provider_name, model_name = model.split("/", 2)
39
38
  unless [provider_name, model_name].all? { |part| part.is_a?(String) && !part.strip.empty? }
40
39
  raise Riffer::ArgumentError, "Invalid model string: #{model}"
41
40
  end
42
41
 
43
42
  @model = model
44
- @provider_options = provider_options
45
43
  end
46
44
 
47
45
  # Evaluates an input/output pair using the configured LLM.
@@ -92,7 +90,7 @@ class Riffer::Evals::Judge
92
90
  provider_class = Riffer::Providers::Repository.find(provider_name)
93
91
  raise Riffer::ArgumentError, "Provider not found: #{provider_name}" unless provider_class
94
92
 
95
- provider_class.new(**@provider_options)
93
+ provider_class.new
96
94
  end
97
95
  end
98
96
 
@@ -37,28 +37,40 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
37
37
  end
38
38
 
39
39
  #--
40
- #: (?api_token: String?, ?region: String?, **untyped) -> void
41
- def initialize(api_token: nil, region: nil, **)
42
- super()
40
+ #: () -> void
41
+ def initialize
42
+ super
43
43
  depends_on "aws-sdk-bedrockruntime"
44
-
45
- api_token ||= Riffer.config.amazon_bedrock.api_token
46
- region ||= Riffer.config.amazon_bedrock.region
47
-
48
- @client = if api_token && !api_token.empty?
49
- Aws::BedrockRuntime::Client.new(
50
- region: region,
51
- token_provider: Aws::StaticTokenProvider.new(api_token),
52
- auth_scheme_preference: ["httpBearerAuth"],
53
- **,
54
- )
55
- else
56
- Aws::BedrockRuntime::Client.new(region: region, **)
57
- end
58
44
  end
59
45
 
60
46
  private
61
47
 
48
+ #--
49
+ #: () -> untyped
50
+ def global_client
51
+ Riffer.config.amazon_bedrock.client
52
+ end
53
+
54
+ # Compacted so an unset region stays absent: the AWS SDK resolves +AWS_REGION+
55
+ # and the shared config only for a missing argument, and raises
56
+ # +Aws::Errors::MissingRegionError+ on an explicit nil.
57
+ #--
58
+ #: () -> untyped
59
+ def build_client
60
+ api_token = Riffer.config.amazon_bedrock.api_token
61
+ region = Riffer.config.amazon_bedrock.region
62
+
63
+ if api_token && !api_token.empty?
64
+ Aws::BedrockRuntime::Client.new(**{
65
+ region: region,
66
+ token_provider: Aws::StaticTokenProvider.new(api_token),
67
+ auth_scheme_preference: ["httpBearerAuth"],
68
+ }.compact)
69
+ else
70
+ Aws::BedrockRuntime::Client.new(**{ region: region }.compact)
71
+ end
72
+ end
73
+
62
74
  #--
63
75
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
64
76
  def build_request_params(messages, model, options)
@@ -137,7 +149,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
137
149
  #--
138
150
  #: (Hash[Symbol, untyped]) -> untyped
139
151
  def execute_generate(params)
140
- @client.converse(**params)
152
+ client.converse(**params)
141
153
  end
142
154
 
143
155
  #--
@@ -227,7 +239,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
227
239
  tool_call: nil,
228
240
  } #: Hash[Symbol, untyped]
229
241
 
230
- @client.converse_stream(**params) do |stream|
242
+ client.converse_stream(**params) do |stream|
231
243
  stream.on_event do |event|
232
244
  case event
233
245
  when Aws::BedrockRuntime::Types::ContentBlockStartEvent
@@ -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
  )
@@ -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,7 @@ 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
13
14
 
14
15
  WIRE_SEPARATOR = "__" #: String
15
16
 
@@ -105,6 +106,33 @@ class Riffer::Providers::Base
105
106
  Riffer::Helpers::Dependencies.depends_on(gem_name)
106
107
  end
107
108
 
109
+ # Returns the client for the current LLM call. A configured client wins,
110
+ # resolved on every call so a Proc can vary the client by process or
111
+ # credential lifetime; otherwise the provider builds one from the configured
112
+ # credentials, memoized for the life of the provider.
113
+ #--
114
+ #: () -> untyped
115
+ def client
116
+ configured = global_client
117
+ return Riffer::Helpers::CallOrValue.resolve(configured) if configured
118
+
119
+ @client ||= build_client
120
+ end
121
+
122
+ # Returns the consumer-configured client for this provider; nil when none is
123
+ # configured, and for providers that take no configuration at all.
124
+ #--
125
+ #: () -> untyped
126
+ def global_client
127
+ nil
128
+ end
129
+
130
+ #--
131
+ #: () -> untyped
132
+ def build_client
133
+ raise NotImplementedError, "Subclasses must implement #build_client"
134
+ end
135
+
108
136
  #--
109
137
  #: (String) -> String
110
138
  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