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.
- checksums.yaml +4 -4
- data/{AGENTS.md → .claude/CLAUDE.md} +0 -8
- data/.claude/rules/comments.md +13 -0
- data/.claude/rules/rbs-inline.md +93 -0
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +22 -0
- data/README.md +20 -24
- data/docs/AGENTS.md +24 -14
- data/docs/AGENT_LIFECYCLE.md +2 -2
- data/docs/CONFIGURATION.md +105 -21
- data/docs/SERIALIZATION.md +4 -3
- data/docs/TOOLS.md +18 -1
- data/docs/providers/AMAZON_BEDROCK.md +14 -8
- data/docs/providers/ANTHROPIC.md +9 -7
- data/docs/providers/AZURE_OPENAI.md +12 -12
- data/docs/providers/CUSTOM_PROVIDERS.md +32 -16
- data/docs/providers/GEMINI.md +28 -5
- data/docs/providers/OPENAI.md +21 -5
- data/docs/providers/OPENROUTER.md +11 -6
- data/docs/providers/PROVIDERS.md +16 -1
- data/lib/riffer/agent/config.rb +0 -6
- data/lib/riffer/agent/run.rb +2 -4
- data/lib/riffer/agent/serializer.rb +0 -2
- data/lib/riffer/agent.rb +4 -26
- data/lib/riffer/config.rb +6 -6
- data/lib/riffer/evals/judge.rb +3 -5
- data/lib/riffer/guardrail.rb +1 -1
- data/lib/riffer/helpers/identifier.rb +41 -0
- data/lib/riffer/providers/amazon_bedrock.rb +31 -19
- data/lib/riffer/providers/anthropic.rb +23 -8
- data/lib/riffer/providers/azure_open_ai.rb +19 -11
- data/lib/riffer/providers/base.rb +32 -1
- data/lib/riffer/providers/gemini/client.rb +120 -0
- data/lib/riffer/providers/gemini.rb +13 -62
- data/lib/riffer/providers/mock.rb +5 -4
- data/lib/riffer/providers/open_ai.rb +23 -8
- data/lib/riffer/providers/open_router.rb +23 -9
- data/lib/riffer/registrable.rb +81 -0
- data/lib/riffer/tool.rb +1 -0
- data/lib/riffer/tools/toolable.rb +2 -3
- data/lib/riffer/version.rb +1 -1
- data/lib/riffer.rb +3 -0
- data/sig/_private/riffer/providers/amazon_bedrock.rbs +4 -2
- data/sig/_private/riffer/providers/anthropic.rbs +4 -2
- data/sig/_private/riffer/providers/gemini.rbs +7 -0
- data/sig/_private/riffer/providers/open_ai.rbs +4 -2
- data/sig/_private/riffer/providers/open_router.rbs +4 -2
- data/sig/generated/riffer/agent/config.rbs +1 -5
- data/sig/generated/riffer/agent.rbs +2 -18
- data/sig/generated/riffer/config.rbs +25 -15
- data/sig/generated/riffer/evals/judge.rbs +2 -4
- data/sig/generated/riffer/helpers/identifier.rbs +19 -0
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +13 -2
- data/sig/generated/riffer/providers/anthropic.rbs +13 -2
- data/sig/generated/riffer/providers/azure_open_ai.rbs +14 -4
- data/sig/generated/riffer/providers/base.rbs +22 -0
- data/sig/generated/riffer/providers/gemini/client.rbs +65 -0
- data/sig/generated/riffer/providers/gemini.rbs +7 -23
- data/sig/generated/riffer/providers/mock.rbs +4 -3
- data/sig/generated/riffer/providers/open_ai.rbs +13 -2
- data/sig/generated/riffer/providers/open_router.rbs +16 -3
- data/sig/generated/riffer/registrable.rbs +51 -0
- data/sig/generated/riffer/tool.rbs +2 -0
- data/sig/generated/riffer/tools/toolable.rbs +3 -1
- data/sig/generated/riffer.rbs +4 -0
- data/sig/manual/riffer/agent.rbs +7 -0
- data/sig/manual/riffer/helpers/identifier.rbs +5 -0
- data/sig/manual/riffer/tool.rbs +7 -0
- metadata +14 -11
- data/.agents/architecture.md +0 -265
- data/.agents/code-style.md +0 -110
- data/.agents/providers.md +0 -45
- data/.agents/rbs-inline.md +0 -169
- data/.agents/testing.md +0 -60
- data/CLAUDE.md +0 -1
- data/lib/riffer/helpers/class_name_converter.rb +0 -22
- data/sig/generated/riffer/helpers/class_name_converter.rbs +0 -12
- 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
|
-
#: (
|
|
34
|
-
def initialize
|
|
35
|
-
super
|
|
33
|
+
#: () -> void
|
|
34
|
+
def initialize
|
|
35
|
+
super
|
|
36
36
|
depends_on "anthropic"
|
|
37
|
+
end
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
private
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
#--
|
|
42
|
+
#: () -> untyped
|
|
43
|
+
def global_client
|
|
44
|
+
Riffer.config.anthropic.client
|
|
41
45
|
end
|
|
42
46
|
|
|
43
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
6
|
-
# +
|
|
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
|
-
#: (
|
|
17
|
-
def
|
|
18
|
-
api_key =
|
|
19
|
-
|
|
20
|
-
|
|
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::
|
|
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
|
-
#: (
|
|
41
|
-
def
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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+)
|
|
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
|
-
#: (
|
|
40
|
-
def initialize(
|
|
40
|
+
#: (?responses: Array[Hash[Symbol, untyped]]) -> void
|
|
41
|
+
def initialize(responses: [])
|
|
41
42
|
super()
|
|
42
|
-
@responses =
|
|
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
|
-
#: (
|
|
17
|
-
def initialize
|
|
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
|
-
|
|
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 =
|
|
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+
|
|
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
|
-
#: (
|
|
31
|
-
def initialize
|
|
32
|
-
super
|
|
30
|
+
#: () -> void
|
|
31
|
+
def initialize
|
|
32
|
+
super
|
|
33
33
|
depends_on "openai"
|
|
34
|
+
end
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
private
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
#--
|
|
39
|
+
#: () -> untyped
|
|
40
|
+
def global_client
|
|
41
|
+
Riffer.config.openrouter.client
|
|
38
42
|
end
|
|
39
43
|
|
|
40
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
@@ -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
|
data/lib/riffer/version.rb
CHANGED
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
|
#--
|