riffer 0.33.0 → 0.35.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/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +19 -0
- data/docs/03_AGENTS.md +44 -0
- data/docs/10_CONFIGURATION.md +15 -15
- data/docs/16_TRACING.md +32 -4
- data/docs/17_METRICS.md +36 -4
- data/lib/riffer/agent/run.rb +82 -49
- data/lib/riffer/agent.rb +20 -13
- data/lib/riffer/config.rb +23 -24
- data/lib/riffer/guardrails/runner.rb +40 -11
- data/lib/riffer/metrics/instruments.rb +6 -0
- data/lib/riffer/metrics/{null.rb → no_op.rb} +1 -1
- data/lib/riffer/metrics/otel.rb +4 -3
- data/lib/riffer/metrics.rb +7 -6
- data/lib/riffer/providers/amazon_bedrock.rb +7 -1
- data/lib/riffer/providers/anthropic.rb +8 -1
- data/lib/riffer/providers/base.rb +38 -26
- data/lib/riffer/providers/gemini.rb +5 -1
- data/lib/riffer/providers/open_ai.rb +10 -1
- data/lib/riffer/providers/open_router.rb +10 -1
- data/lib/riffer/tools/runtime.rb +30 -20
- data/lib/riffer/tracing/{null.rb → no_op.rb} +3 -3
- data/lib/riffer/tracing/otel.rb +4 -3
- data/lib/riffer/tracing.rb +9 -9
- data/lib/riffer/version.rb +1 -1
- data/sig/generated/riffer/agent/run.rbs +47 -30
- data/sig/generated/riffer/agent.rbs +16 -9
- data/sig/generated/riffer/config.rbs +14 -14
- data/sig/generated/riffer/guardrails/runner.rbs +15 -4
- data/sig/generated/riffer/metrics/instruments.rbs +2 -0
- data/sig/generated/riffer/metrics/{null.rbs → no_op.rbs} +2 -2
- data/sig/generated/riffer/metrics/otel.rbs +4 -3
- data/sig/generated/riffer/metrics.rbs +7 -6
- data/sig/generated/riffer/providers/base.rbs +26 -20
- data/sig/generated/riffer/tools/runtime.rbs +24 -18
- data/sig/generated/riffer/tracing/{null.rbs → no_op.rbs} +5 -5
- data/sig/generated/riffer/tracing/otel.rbs +4 -3
- data/sig/generated/riffer/tracing.rbs +9 -9
- data/sig/manual/riffer/metrics/no_op.rbs +5 -0
- data/sig/manual/riffer/tracing/no_op.rbs +5 -0
- metadata +11 -11
- data/sig/manual/riffer/metrics/null.rbs +0 -5
- data/sig/manual/riffer/tracing/null.rbs +0 -5
data/lib/riffer/agent.rb
CHANGED
|
@@ -169,16 +169,16 @@ class Riffer::Agent
|
|
|
169
169
|
|
|
170
170
|
# Generates a response using a new agent instance.
|
|
171
171
|
#--
|
|
172
|
-
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
|
|
173
|
-
def self.generate(prompt = nil, files: nil, context: nil)
|
|
174
|
-
new(context: context).generate(prompt, files: files)
|
|
172
|
+
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
|
|
173
|
+
def self.generate(prompt = nil, files: nil, context: nil, tags: {})
|
|
174
|
+
new(context: context).generate(prompt, files: files, tags: tags)
|
|
175
175
|
end
|
|
176
176
|
|
|
177
177
|
# Streams a response using a new agent instance.
|
|
178
178
|
#--
|
|
179
|
-
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
180
|
-
def self.stream(prompt = nil, files: nil, context: nil)
|
|
181
|
-
new(context: context).stream(prompt, files: files)
|
|
179
|
+
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
180
|
+
def self.stream(prompt = nil, files: nil, context: nil, tags: {})
|
|
181
|
+
new(context: context).stream(prompt, files: files, tags: tags)
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
# Reconstructs a runnable agent from a wire hash produced by +#to_h+.
|
|
@@ -288,22 +288,29 @@ class Riffer::Agent
|
|
|
288
288
|
# against the current session, resuming a persisted conversation or pending
|
|
289
289
|
# tool calls. +files:+ requires +prompt+.
|
|
290
290
|
#
|
|
291
|
+
# +tags:+ is an optional flat hash of attribution tags applied to this single
|
|
292
|
+
# call: they propagate to the provider's native request-metadata field, and
|
|
293
|
+
# are stamped as +riffer.tag.*+ on every span and metric the call emits. See
|
|
294
|
+
# +docs/03_AGENTS.md+ for the per-provider mapping. The reserved key
|
|
295
|
+
# +user_id+ also maps to the provider's native user identifier where one
|
|
296
|
+
# exists.
|
|
297
|
+
#
|
|
291
298
|
#--
|
|
292
|
-
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
|
|
293
|
-
def generate(prompt = nil, files: nil)
|
|
294
|
-
Riffer::Agent::Run.generate(agent: self, prompt: prompt, files: files)
|
|
299
|
+
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
|
|
300
|
+
def generate(prompt = nil, files: nil, tags: {})
|
|
301
|
+
Riffer::Agent::Run.generate(agent: self, prompt: prompt, files: files, tags: tags)
|
|
295
302
|
end
|
|
296
303
|
|
|
297
304
|
# Streams a response from the agent, returning an +Enumerator+ of
|
|
298
|
-
# +Riffer::StreamEvents+. See +#generate+ for prompt/files semantics.
|
|
305
|
+
# +Riffer::StreamEvents+. See +#generate+ for prompt/files/tags semantics.
|
|
299
306
|
#
|
|
300
307
|
# Raises Riffer::ArgumentError if structured output is configured.
|
|
301
308
|
#
|
|
302
309
|
#--
|
|
303
|
-
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
304
|
-
def stream(prompt = nil, files: nil)
|
|
310
|
+
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
311
|
+
def stream(prompt = nil, files: nil, tags: {})
|
|
305
312
|
raise Riffer::ArgumentError, "Structured output is not supported with streaming. Use #generate instead." if @structured_output
|
|
306
|
-
Riffer::Agent::Run.stream(agent: self, prompt: prompt, files: files)
|
|
313
|
+
Riffer::Agent::Run.stream(agent: self, prompt: prompt, files: files, tags: tags)
|
|
307
314
|
end
|
|
308
315
|
|
|
309
316
|
# Interrupts the agent loop from an +on_message+ callback. Equivalent to
|
data/lib/riffer/config.rb
CHANGED
|
@@ -61,16 +61,16 @@ class Riffer::Config
|
|
|
61
61
|
# content routinely carries sensitive data.
|
|
62
62
|
attr_reader :capture_messages #: bool
|
|
63
63
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
attr_reader :
|
|
64
|
+
# The backend riffer routes spans through; defaults to +nil+, a no-op.
|
|
65
|
+
# Riffer auto-detects no backend; assigning one is opt-in.
|
|
66
|
+
attr_reader :backend #: untyped
|
|
67
67
|
|
|
68
68
|
#--
|
|
69
69
|
#: () -> void
|
|
70
70
|
def initialize
|
|
71
71
|
@enabled = true
|
|
72
72
|
@capture_messages = false
|
|
73
|
-
@
|
|
73
|
+
@backend = nil
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
# Sets the enabled flag, coercing boolean-ish values so an env-var
|
|
@@ -91,17 +91,17 @@ class Riffer::Config
|
|
|
91
91
|
@capture_messages = Riffer::Helpers::Boolean.coerce(value, attribute: "capture_messages")
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
# Sets
|
|
95
|
-
# Riffer::ArgumentError
|
|
96
|
-
#
|
|
94
|
+
# Sets the tracing backend riffer routes spans through. Raises
|
|
95
|
+
# Riffer::ArgumentError unless the value is +nil+ or responds to the full
|
|
96
|
+
# delegated contract: +in_span+, +current_context+, and +with_context+.
|
|
97
97
|
#--
|
|
98
98
|
#: (untyped) -> void
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
def backend=(value)
|
|
100
|
+
contract = %i[in_span current_context with_context]
|
|
101
|
+
unless value.nil? || contract.all? { |method| value.respond_to?(method) }
|
|
102
|
+
raise Riffer::ArgumentError, "tracing backend must respond to #in_span, #current_context, and #with_context"
|
|
103
103
|
end
|
|
104
|
-
@
|
|
104
|
+
@backend = value
|
|
105
105
|
Riffer::Tracing.reset!
|
|
106
106
|
end
|
|
107
107
|
end
|
|
@@ -113,15 +113,15 @@ class Riffer::Config
|
|
|
113
113
|
# no-op until a host wires an OTEL metrics SDK.
|
|
114
114
|
attr_reader :enabled #: bool
|
|
115
115
|
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
attr_reader :
|
|
116
|
+
# The backend riffer routes measurements through; defaults to +nil+, a no-op.
|
|
117
|
+
# Riffer auto-detects no backend; assigning one is opt-in.
|
|
118
|
+
attr_reader :backend #: untyped
|
|
119
119
|
|
|
120
120
|
#--
|
|
121
121
|
#: () -> void
|
|
122
122
|
def initialize
|
|
123
123
|
@enabled = true
|
|
124
|
-
@
|
|
124
|
+
@backend = nil
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
# Sets the enabled flag, coercing boolean-ish values so an env-var
|
|
@@ -133,17 +133,16 @@ class Riffer::Config
|
|
|
133
133
|
@enabled = Riffer::Helpers::Boolean.coerce(value, attribute: "enabled")
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
-
# Sets
|
|
137
|
-
# Riffer::ArgumentError
|
|
138
|
-
#
|
|
136
|
+
# Sets the metrics backend riffer routes measurements through. Raises
|
|
137
|
+
# Riffer::ArgumentError unless the value is +nil+ or responds to
|
|
138
|
+
# +record_histogram+.
|
|
139
139
|
#--
|
|
140
140
|
#: (untyped) -> void
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
raise Riffer::ArgumentError,
|
|
144
|
-
"meter_provider requires the opentelemetry-metrics-api gem (#{Riffer::Metrics::Otel::SUPPORTED_API_VERSIONS})"
|
|
141
|
+
def backend=(value)
|
|
142
|
+
unless value.nil? || value.respond_to?(:record_histogram)
|
|
143
|
+
raise Riffer::ArgumentError, "metrics backend must respond to #record_histogram"
|
|
145
144
|
end
|
|
146
|
-
@
|
|
145
|
+
@backend = value
|
|
147
146
|
Riffer::Metrics.reset!
|
|
148
147
|
end
|
|
149
148
|
end
|
|
@@ -13,12 +13,16 @@ class Riffer::Guardrails::Runner
|
|
|
13
13
|
# The context passed to guardrails.
|
|
14
14
|
attr_reader :context #: untyped
|
|
15
15
|
|
|
16
|
+
# The normalized per-call tags, stamped as +riffer.tag.*+ on guardrail spans.
|
|
17
|
+
attr_reader :tags #: Hash[String, String]
|
|
18
|
+
|
|
16
19
|
#--
|
|
17
|
-
#: (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped) -> void
|
|
18
|
-
def initialize(guardrail_configs, phase:, context: nil)
|
|
20
|
+
#: (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped, ?tags: Hash[String, String]) -> void
|
|
21
|
+
def initialize(guardrail_configs, phase:, context: nil, tags: {})
|
|
19
22
|
@guardrail_configs = guardrail_configs
|
|
20
23
|
@phase = phase
|
|
21
24
|
@context = context
|
|
25
|
+
@tags = tags
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
# Runs the guardrails sequentially. For the +:before+ phase +data+ is the
|
|
@@ -81,15 +85,32 @@ class Riffer::Guardrails::Runner
|
|
|
81
85
|
#--
|
|
82
86
|
#: (Riffer::Guardrail, untyped, messages: Array[Riffer::Messages::Base]?) -> Riffer::Guardrails::Result
|
|
83
87
|
def execute_guardrail(guardrail, data, messages:)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
instrument_guardrail(guardrail) do
|
|
89
|
+
Riffer::Tracing.in_span("execute_guardrail #{guardrail.name}", attributes: guardrail_span_attributes(guardrail), kind: :internal) do |span|
|
|
90
|
+
result = run_guardrail_phase(guardrail, data, messages: messages)
|
|
91
|
+
record_guardrail_outcome(span, result)
|
|
92
|
+
result
|
|
93
|
+
rescue => error
|
|
94
|
+
# The backend records the exception and error status on the re-raise;
|
|
95
|
+
# error.type is the one semconv attribute it doesn't set.
|
|
96
|
+
span.set_attribute("error.type", error.class.name)
|
|
97
|
+
raise
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
#--
|
|
103
|
+
#: (Riffer::Guardrail) { () -> Riffer::Guardrails::Result } -> Riffer::Guardrails::Result
|
|
104
|
+
def instrument_guardrail(guardrail)
|
|
105
|
+
start = Riffer::Metrics.monotonic_now
|
|
106
|
+
error_type = nil #: String?
|
|
107
|
+
begin
|
|
108
|
+
yield
|
|
88
109
|
rescue => error
|
|
89
|
-
|
|
90
|
-
# error.type is the one semconv attribute it doesn't set.
|
|
91
|
-
span.set_attribute("error.type", error.class.name)
|
|
110
|
+
error_type = error.class.name #: String?
|
|
92
111
|
raise
|
|
112
|
+
ensure
|
|
113
|
+
Riffer::Metrics::Instruments::GUARDRAIL_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: guardrail_metric_attributes(guardrail, error_type))
|
|
93
114
|
end
|
|
94
115
|
end
|
|
95
116
|
|
|
@@ -112,13 +133,21 @@ class Riffer::Guardrails::Runner
|
|
|
112
133
|
{
|
|
113
134
|
"riffer.guardrail.name" => guardrail.name,
|
|
114
135
|
"riffer.guardrail.phase" => phase.to_s
|
|
115
|
-
}
|
|
136
|
+
}.merge(tags.transform_keys { |key| "riffer.tag.#{key}" })
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
#--
|
|
140
|
+
#: (Riffer::Guardrail, String?) -> Hash[String, untyped]
|
|
141
|
+
def guardrail_metric_attributes(guardrail, error_type)
|
|
142
|
+
attributes = guardrail_span_attributes(guardrail)
|
|
143
|
+
attributes["error.type"] = error_type if error_type
|
|
144
|
+
attributes
|
|
116
145
|
end
|
|
117
146
|
|
|
118
147
|
# A block is a handled outcome, so its span status stays unset — an error
|
|
119
148
|
# span status is reserved for a raised exception.
|
|
120
149
|
#--
|
|
121
|
-
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::
|
|
150
|
+
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Guardrails::Result) -> void
|
|
122
151
|
def record_guardrail_outcome(span, result)
|
|
123
152
|
span.set_attribute("riffer.guardrail.action", result.type.to_s)
|
|
124
153
|
span.set_attribute("riffer.tripwire.reason", result.data) if result.block?
|
|
@@ -22,4 +22,10 @@ module Riffer::Metrics::Instruments # :nodoc: all
|
|
|
22
22
|
unit: "USD",
|
|
23
23
|
description: "Cost of GenAI client operations in USD"
|
|
24
24
|
) #: Riffer::Metrics::Histogram
|
|
25
|
+
|
|
26
|
+
GUARDRAIL_DURATION = Riffer::Metrics.create_histogram(
|
|
27
|
+
"riffer.guardrail.duration",
|
|
28
|
+
unit: "s",
|
|
29
|
+
description: "Duration of guardrail execution"
|
|
30
|
+
) #: Riffer::Metrics::Histogram
|
|
25
31
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# No-op metrics backend, used when the OpenTelemetry metrics API is unavailable
|
|
5
5
|
# or metrics are disabled.
|
|
6
|
-
module Riffer::Metrics::
|
|
6
|
+
module Riffer::Metrics::NoOp # :nodoc: all
|
|
7
7
|
extend self
|
|
8
8
|
|
|
9
9
|
# Ignores the measurement; there is no meter without the OTEL metrics API.
|
data/lib/riffer/metrics/otel.rb
CHANGED
|
@@ -9,10 +9,11 @@ class Riffer::Metrics::Otel # :nodoc: all
|
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
11
|
# Builds a backend when the OpenTelemetry metrics API is loadable at a
|
|
12
|
-
# supported version; returns +nil+ so resolution falls back to
|
|
12
|
+
# supported version; returns +nil+ so resolution falls back to NoOp.
|
|
13
|
+
# +provider+ defaults to the global <tt>OpenTelemetry.meter_provider</tt>.
|
|
13
14
|
#--
|
|
14
|
-
#: (provider: untyped) -> Riffer::Metrics::Otel?
|
|
15
|
-
def build(provider:)
|
|
15
|
+
#: (?provider: untyped) -> Riffer::Metrics::Otel?
|
|
16
|
+
def build(provider: nil)
|
|
16
17
|
version = api_version
|
|
17
18
|
return nil unless version
|
|
18
19
|
|
data/lib/riffer/metrics.rb
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
module Riffer::Metrics # :nodoc: all
|
|
8
8
|
extend self
|
|
9
9
|
|
|
10
|
-
# @rbs @backend:
|
|
10
|
+
# @rbs @backend: untyped
|
|
11
11
|
|
|
12
12
|
MUTEX = Mutex.new #: Mutex
|
|
13
13
|
|
|
@@ -54,11 +54,12 @@ module Riffer::Metrics # :nodoc: all
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
# Mirrors a span's +recording?+ so a caller can skip work that exists only to
|
|
57
|
-
# feed a metric.
|
|
57
|
+
# feed a metric. True for any live backend — the OTEL backend or a
|
|
58
|
+
# consumer-supplied one — so a custom metrics sink still gets fed.
|
|
58
59
|
#--
|
|
59
60
|
#: () -> bool
|
|
60
61
|
def recording?
|
|
61
|
-
Riffer.config.metrics.enabled && backend.
|
|
62
|
+
Riffer.config.metrics.enabled && !backend.equal?(NoOp)
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
# Reads the monotonic clock in seconds — the time source for duration metrics,
|
|
@@ -80,14 +81,14 @@ module Riffer::Metrics # :nodoc: all
|
|
|
80
81
|
private
|
|
81
82
|
|
|
82
83
|
#--
|
|
83
|
-
#: () ->
|
|
84
|
+
#: () -> untyped
|
|
84
85
|
def backend
|
|
85
86
|
@backend || MUTEX.synchronize { @backend ||= resolve_backend }
|
|
86
87
|
end
|
|
87
88
|
|
|
88
89
|
#--
|
|
89
|
-
#: () ->
|
|
90
|
+
#: () -> untyped
|
|
90
91
|
def resolve_backend
|
|
91
|
-
|
|
92
|
+
Riffer.config.metrics.backend || NoOp
|
|
92
93
|
end
|
|
93
94
|
end
|
|
@@ -64,14 +64,20 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
|
|
|
64
64
|
tools = options[:tools]
|
|
65
65
|
structured_output = options[:structured_output]
|
|
66
66
|
cache_control = options[:cache_control]
|
|
67
|
+
tags = options[:tags] || {}
|
|
67
68
|
|
|
68
69
|
params = {
|
|
69
70
|
model_id: model,
|
|
70
71
|
system: partitioned_messages[:system],
|
|
71
72
|
messages: partitioned_messages[:conversation],
|
|
72
|
-
**options.except(:tools, :structured_output, :cache_control)
|
|
73
|
+
**options.except(:tools, :structured_output, :cache_control, :tags)
|
|
73
74
|
} #: Hash[Symbol, untyped]
|
|
74
75
|
|
|
76
|
+
# requestMetadata is a flat String=>String map used to filter invocation
|
|
77
|
+
# logs; every tag (including the reserved user_id) rides along, since
|
|
78
|
+
# Converse has no dedicated end-user field.
|
|
79
|
+
params[:request_metadata] = tags unless tags.empty?
|
|
80
|
+
|
|
75
81
|
if tools && !tools.empty?
|
|
76
82
|
params[:tool_config] = {
|
|
77
83
|
tools: tools.map { |t| convert_tool_to_bedrock_format(t) }
|
|
@@ -48,6 +48,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
|
|
|
48
48
|
tools = options[:tools]
|
|
49
49
|
structured_output = options[:structured_output]
|
|
50
50
|
web_search = options[:web_search]
|
|
51
|
+
tags = options[:tags] || {}
|
|
51
52
|
|
|
52
53
|
max_tokens = options.fetch(:max_tokens, 4096)
|
|
53
54
|
|
|
@@ -55,11 +56,17 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
|
|
|
55
56
|
model: model,
|
|
56
57
|
messages: partitioned_messages[:conversation],
|
|
57
58
|
max_tokens: max_tokens,
|
|
58
|
-
**options.except(:tools, :max_tokens, :structured_output, :web_search)
|
|
59
|
+
**options.except(:tools, :max_tokens, :structured_output, :web_search, :tags)
|
|
59
60
|
} #: Hash[Symbol, untyped]
|
|
60
61
|
|
|
61
62
|
params[:system] = partitioned_messages[:system] if partitioned_messages[:system]
|
|
62
63
|
|
|
64
|
+
# Anthropic's only request-metadata field is metadata.user_id (opaque, no
|
|
65
|
+
# PII). It carries the reserved user_id tag; all other tags are dropped
|
|
66
|
+
# here and survive only on spans/metrics.
|
|
67
|
+
user_id = tags["user_id"]
|
|
68
|
+
params[:metadata] = {user_id: user_id} if user_id
|
|
69
|
+
|
|
63
70
|
anthropic_tools = [] #: Array[Hash[Symbol, untyped]]
|
|
64
71
|
anthropic_tools.concat(tools.map { |t| convert_tool_to_anthropic_format(t) }) if tools && !tools.empty?
|
|
65
72
|
|
|
@@ -46,6 +46,7 @@ class Riffer::Providers::Base
|
|
|
46
46
|
validate_normalized_messages!(messages)
|
|
47
47
|
messages = merge_consecutive_messages(messages)
|
|
48
48
|
params = build_request_params(messages, model, options)
|
|
49
|
+
tags = options[:tags] || {}
|
|
49
50
|
|
|
50
51
|
in_chat_span(model, messages, options) do |span|
|
|
51
52
|
response = execute_generate(params)
|
|
@@ -57,8 +58,8 @@ class Riffer::Providers::Base
|
|
|
57
58
|
structured_output = parse_structured_output(content) if options[:structured_output] && tool_calls.empty?
|
|
58
59
|
|
|
59
60
|
Riffer::Tracing.record_usage(span, token_usage)
|
|
60
|
-
record_token_usage_metric(model, token_usage)
|
|
61
|
-
record_cost_metric(model, token_usage)
|
|
61
|
+
record_token_usage_metric(model, token_usage, tags)
|
|
62
|
+
record_cost_metric(model, token_usage, tags)
|
|
62
63
|
record_finish_reason(span, finish_reason&.reason, finish_reason&.raw)
|
|
63
64
|
capture_output(span, content: content, tool_calls: tool_calls, finish_reason: finish_reason&.reason)
|
|
64
65
|
|
|
@@ -84,10 +85,12 @@ class Riffer::Providers::Base
|
|
|
84
85
|
validate_normalized_messages!(messages)
|
|
85
86
|
messages = merge_consecutive_messages(messages)
|
|
86
87
|
params = build_request_params(messages, model, options)
|
|
88
|
+
tags = options[:tags] || {}
|
|
87
89
|
|
|
88
90
|
# The enumerator body runs in its own fiber, where the fiber-local OTEL
|
|
89
|
-
# context is empty — capture here so the chat span parents to the
|
|
90
|
-
#
|
|
91
|
+
# context is empty — capture here so the chat span parents to the caller's
|
|
92
|
+
# trace. tags are an ordinary local captured in the closure, so they reach
|
|
93
|
+
# the span/metric builders without re-propagation.
|
|
91
94
|
trace_context = Riffer::Tracing.current_context
|
|
92
95
|
Enumerator.new do |yielder|
|
|
93
96
|
Riffer::Tracing.with_context(trace_context) do
|
|
@@ -99,8 +102,8 @@ class Riffer::Providers::Base
|
|
|
99
102
|
execute_stream(params, sink)
|
|
100
103
|
if sink.is_a?(Riffer::Tracing::StreamRecorder)
|
|
101
104
|
record_stream_outcome(span, sink)
|
|
102
|
-
record_token_usage_metric(model, sink.token_usage)
|
|
103
|
-
record_cost_metric(model, sink.token_usage)
|
|
105
|
+
record_token_usage_metric(model, sink.token_usage, tags)
|
|
106
|
+
record_cost_metric(model, sink.token_usage, tags)
|
|
104
107
|
end
|
|
105
108
|
end
|
|
106
109
|
end
|
|
@@ -220,10 +223,11 @@ class Riffer::Providers::Base
|
|
|
220
223
|
}.freeze #: Hash[Symbol, String]
|
|
221
224
|
|
|
222
225
|
#--
|
|
223
|
-
#: [R] (String?, Array[Riffer::Messages::Base], Hash[Symbol, untyped]) { (Riffer::Tracing::Otel::Span | Riffer::Tracing::
|
|
226
|
+
#: [R] (String?, Array[Riffer::Messages::Base], Hash[Symbol, untyped]) { (Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span) -> R } -> R
|
|
224
227
|
def in_chat_span(model, messages, options)
|
|
225
228
|
start = Riffer::Metrics.monotonic_now
|
|
226
229
|
error_type = nil #: String?
|
|
230
|
+
tags = options[:tags] || {} #: Hash[String, String]
|
|
227
231
|
begin
|
|
228
232
|
Riffer::Tracing.in_span(model ? "chat #{model}" : "chat", attributes: chat_span_attributes(model, options), kind: :client) do |span|
|
|
229
233
|
capture_input(span, messages)
|
|
@@ -240,7 +244,7 @@ class Riffer::Providers::Base
|
|
|
240
244
|
error_type = error.class.name #: String?
|
|
241
245
|
raise
|
|
242
246
|
ensure
|
|
243
|
-
Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: chat_metric_attributes(model, error_type))
|
|
247
|
+
Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: chat_metric_attributes(model, error_type, tags))
|
|
244
248
|
end
|
|
245
249
|
end
|
|
246
250
|
|
|
@@ -258,51 +262,59 @@ class Riffer::Providers::Base
|
|
|
258
262
|
attributes[attribute] = value unless value.nil?
|
|
259
263
|
end
|
|
260
264
|
|
|
261
|
-
attributes
|
|
265
|
+
attributes.merge(tag_attributes(options[:tags] || {}))
|
|
262
266
|
end
|
|
263
267
|
|
|
264
268
|
#--
|
|
265
|
-
#: (String?) -> Hash[String, untyped]
|
|
266
|
-
def chat_metric_base_attributes(model)
|
|
269
|
+
#: (String?, ?Hash[String, String]) -> Hash[String, untyped]
|
|
270
|
+
def chat_metric_base_attributes(model, tags = {})
|
|
267
271
|
attributes = {
|
|
268
272
|
"gen_ai.operation.name" => "chat",
|
|
269
273
|
"gen_ai.provider.name" => self.class.semconv_provider_name
|
|
270
274
|
} #: Hash[String, untyped]
|
|
271
275
|
attributes["gen_ai.request.model"] = model if model
|
|
272
|
-
attributes
|
|
276
|
+
attributes.merge(tag_attributes(tags))
|
|
273
277
|
end
|
|
274
278
|
|
|
275
279
|
#--
|
|
276
|
-
#: (String?, String?) -> Hash[String, untyped]
|
|
277
|
-
def chat_metric_attributes(model, error_type)
|
|
278
|
-
attributes = chat_metric_base_attributes(model)
|
|
280
|
+
#: (String?, String?, ?Hash[String, String]) -> Hash[String, untyped]
|
|
281
|
+
def chat_metric_attributes(model, error_type, tags = {})
|
|
282
|
+
attributes = chat_metric_base_attributes(model, tags)
|
|
279
283
|
attributes["error.type"] = error_type if error_type
|
|
280
284
|
attributes
|
|
281
285
|
end
|
|
282
286
|
|
|
287
|
+
# Maps normalized tags to their namespaced span/metric attribute form. An
|
|
288
|
+
# empty map yields an empty hash, so merging it is a no-op.
|
|
289
|
+
#--
|
|
290
|
+
#: (Hash[String, String]) -> Hash[String, String]
|
|
291
|
+
def tag_attributes(tags)
|
|
292
|
+
tags.transform_keys { |key| "riffer.tag.#{key}" }
|
|
293
|
+
end
|
|
294
|
+
|
|
283
295
|
# Per-call only — the run level would double-count an aggregate.
|
|
284
296
|
#--
|
|
285
|
-
#: (String?, Riffer::Providers::TokenUsage?) -> void
|
|
286
|
-
def record_token_usage_metric(model, usage)
|
|
297
|
+
#: (String?, Riffer::Providers::TokenUsage?, ?Hash[String, String]) -> void
|
|
298
|
+
def record_token_usage_metric(model, usage, tags = {})
|
|
287
299
|
return unless usage
|
|
288
300
|
|
|
289
|
-
base = chat_metric_base_attributes(model)
|
|
301
|
+
base = chat_metric_base_attributes(model, tags)
|
|
290
302
|
Riffer::Metrics::Instruments::TOKEN_USAGE.record(usage.input_tokens, attributes: base.merge("gen_ai.token.type" => "input"))
|
|
291
303
|
Riffer::Metrics::Instruments::TOKEN_USAGE.record(usage.output_tokens, attributes: base.merge("gen_ai.token.type" => "output"))
|
|
292
304
|
end
|
|
293
305
|
|
|
294
306
|
# Per-call only — the run level would double-count an aggregate.
|
|
295
307
|
#--
|
|
296
|
-
#: (String?, Riffer::Providers::TokenUsage?) -> void
|
|
297
|
-
def record_cost_metric(model, usage)
|
|
308
|
+
#: (String?, Riffer::Providers::TokenUsage?, ?Hash[String, String]) -> void
|
|
309
|
+
def record_cost_metric(model, usage, tags = {})
|
|
298
310
|
cost = usage&.cost
|
|
299
311
|
return unless cost
|
|
300
312
|
|
|
301
|
-
Riffer::Metrics::Instruments::COST.record(cost, attributes: chat_metric_base_attributes(model))
|
|
313
|
+
Riffer::Metrics::Instruments::COST.record(cost, attributes: chat_metric_base_attributes(model, tags))
|
|
302
314
|
end
|
|
303
315
|
|
|
304
316
|
#--
|
|
305
|
-
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::
|
|
317
|
+
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Symbol?, String?) -> void
|
|
306
318
|
def record_finish_reason(span, reason, raw)
|
|
307
319
|
return unless reason
|
|
308
320
|
|
|
@@ -311,7 +323,7 @@ class Riffer::Providers::Base
|
|
|
311
323
|
end
|
|
312
324
|
|
|
313
325
|
#--
|
|
314
|
-
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::
|
|
326
|
+
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Tracing::StreamRecorder) -> void
|
|
315
327
|
def record_stream_outcome(span, recorder)
|
|
316
328
|
Riffer::Tracing.record_usage(span, recorder.token_usage)
|
|
317
329
|
record_finish_reason(span, recorder.finish_reason, recorder.raw_finish_reason)
|
|
@@ -319,7 +331,7 @@ class Riffer::Providers::Base
|
|
|
319
331
|
end
|
|
320
332
|
|
|
321
333
|
#--
|
|
322
|
-
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::
|
|
334
|
+
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Array[Riffer::Messages::Base]) -> void
|
|
323
335
|
def capture_input(span, messages)
|
|
324
336
|
return unless capture_messages?(span)
|
|
325
337
|
|
|
@@ -329,7 +341,7 @@ class Riffer::Providers::Base
|
|
|
329
341
|
end
|
|
330
342
|
|
|
331
343
|
#--
|
|
332
|
-
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::
|
|
344
|
+
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), content: String?, tool_calls: Array[Riffer::Messages::Assistant::ToolCall], finish_reason: Symbol?) -> void
|
|
333
345
|
def capture_output(span, content:, tool_calls:, finish_reason:)
|
|
334
346
|
return unless capture_messages?(span)
|
|
335
347
|
|
|
@@ -337,7 +349,7 @@ class Riffer::Providers::Base
|
|
|
337
349
|
end
|
|
338
350
|
|
|
339
351
|
#--
|
|
340
|
-
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::
|
|
352
|
+
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span)) -> bool
|
|
341
353
|
def capture_messages?(span)
|
|
342
354
|
Riffer.config.tracing.capture_messages && span.recording?
|
|
343
355
|
end
|
|
@@ -67,7 +67,11 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
67
67
|
}]
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
# tags propagate to observability only: the Gemini Developer API has no
|
|
71
|
+
# request labels field (unknown body fields are rejected), so :tags is
|
|
72
|
+
# stripped here rather than mapped. Native labels would arrive with a Vertex
|
|
73
|
+
# adapter. See docs/10_CONFIGURATION.md.
|
|
74
|
+
generation_config = options.except(:tools, :structured_output, :tags)
|
|
71
75
|
|
|
72
76
|
if structured_output
|
|
73
77
|
generation_config[:responseMimeType] = "application/json"
|
|
@@ -30,6 +30,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
30
30
|
tools = options[:tools]
|
|
31
31
|
structured_output = options[:structured_output]
|
|
32
32
|
web_search = options[:web_search]
|
|
33
|
+
tags = options[:tags] || {}
|
|
33
34
|
|
|
34
35
|
params = {
|
|
35
36
|
input: convert_messages_to_openai_format(messages),
|
|
@@ -38,9 +39,17 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
38
39
|
effort: reasoning,
|
|
39
40
|
summary: "auto"
|
|
40
41
|
},
|
|
41
|
-
**options.except(:reasoning, :tools, :structured_output, :web_search)
|
|
42
|
+
**options.except(:reasoning, :tools, :structured_output, :web_search, :tags)
|
|
42
43
|
} #: Hash[Symbol, untyped]
|
|
43
44
|
|
|
45
|
+
unless tags.empty?
|
|
46
|
+
params[:metadata] = tags
|
|
47
|
+
# The reserved user_id also maps to the native safety identifier while
|
|
48
|
+
# staying in metadata as an ordinary tag.
|
|
49
|
+
user_id = tags["user_id"]
|
|
50
|
+
params[:safety_identifier] = user_id if user_id
|
|
51
|
+
end
|
|
52
|
+
|
|
44
53
|
openai_tools = [] #: Array[Hash[Symbol, untyped]]
|
|
45
54
|
openai_tools.concat(tools.map { |t| convert_tool_to_openai_format(t) }) if tools && !tools.empty?
|
|
46
55
|
|
|
@@ -44,13 +44,22 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
|
|
|
44
44
|
reasoning = options[:reasoning]
|
|
45
45
|
tools = options[:tools]
|
|
46
46
|
structured_output = options[:structured_output]
|
|
47
|
+
tags = options[:tags] || {}
|
|
47
48
|
|
|
48
49
|
params = {
|
|
49
50
|
model: model,
|
|
50
51
|
messages: convert_messages_to_chat_completions_format(messages),
|
|
51
|
-
**options.except(:reasoning, :tools, :structured_output)
|
|
52
|
+
**options.except(:reasoning, :tools, :structured_output, :tags)
|
|
52
53
|
} #: Hash[Symbol, untyped]
|
|
53
54
|
|
|
55
|
+
unless tags.empty?
|
|
56
|
+
params[:metadata] = tags
|
|
57
|
+
# OpenRouter exposes the legacy Chat Completions user field rather than
|
|
58
|
+
# safety_identifier; the reserved user_id maps there and stays in metadata.
|
|
59
|
+
user = tags["user_id"]
|
|
60
|
+
params[:user] = user if user
|
|
61
|
+
end
|
|
62
|
+
|
|
54
63
|
if reasoning
|
|
55
64
|
params[:reasoning] = reasoning.is_a?(String) ? {effort: reasoning} : reasoning
|
|
56
65
|
end
|