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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +19 -0
  4. data/docs/03_AGENTS.md +44 -0
  5. data/docs/10_CONFIGURATION.md +15 -15
  6. data/docs/16_TRACING.md +32 -4
  7. data/docs/17_METRICS.md +36 -4
  8. data/lib/riffer/agent/run.rb +82 -49
  9. data/lib/riffer/agent.rb +20 -13
  10. data/lib/riffer/config.rb +23 -24
  11. data/lib/riffer/guardrails/runner.rb +40 -11
  12. data/lib/riffer/metrics/instruments.rb +6 -0
  13. data/lib/riffer/metrics/{null.rb → no_op.rb} +1 -1
  14. data/lib/riffer/metrics/otel.rb +4 -3
  15. data/lib/riffer/metrics.rb +7 -6
  16. data/lib/riffer/providers/amazon_bedrock.rb +7 -1
  17. data/lib/riffer/providers/anthropic.rb +8 -1
  18. data/lib/riffer/providers/base.rb +38 -26
  19. data/lib/riffer/providers/gemini.rb +5 -1
  20. data/lib/riffer/providers/open_ai.rb +10 -1
  21. data/lib/riffer/providers/open_router.rb +10 -1
  22. data/lib/riffer/tools/runtime.rb +30 -20
  23. data/lib/riffer/tracing/{null.rb → no_op.rb} +3 -3
  24. data/lib/riffer/tracing/otel.rb +4 -3
  25. data/lib/riffer/tracing.rb +9 -9
  26. data/lib/riffer/version.rb +1 -1
  27. data/sig/generated/riffer/agent/run.rbs +47 -30
  28. data/sig/generated/riffer/agent.rbs +16 -9
  29. data/sig/generated/riffer/config.rbs +14 -14
  30. data/sig/generated/riffer/guardrails/runner.rbs +15 -4
  31. data/sig/generated/riffer/metrics/instruments.rbs +2 -0
  32. data/sig/generated/riffer/metrics/{null.rbs → no_op.rbs} +2 -2
  33. data/sig/generated/riffer/metrics/otel.rbs +4 -3
  34. data/sig/generated/riffer/metrics.rbs +7 -6
  35. data/sig/generated/riffer/providers/base.rbs +26 -20
  36. data/sig/generated/riffer/tools/runtime.rbs +24 -18
  37. data/sig/generated/riffer/tracing/{null.rbs → no_op.rbs} +5 -5
  38. data/sig/generated/riffer/tracing/otel.rbs +4 -3
  39. data/sig/generated/riffer/tracing.rbs +9 -9
  40. data/sig/manual/riffer/metrics/no_op.rbs +5 -0
  41. data/sig/manual/riffer/tracing/no_op.rbs +5 -0
  42. metadata +11 -11
  43. data/sig/manual/riffer/metrics/null.rbs +0 -5
  44. 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
- # Explicit OTEL tracer provider; defaults to +nil+, which resolves the
65
- # global <tt>OpenTelemetry.tracer_provider</tt> at first span.
66
- attr_reader :tracer_provider #: untyped
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
- @tracer_provider = nil
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 an explicit tracer provider, forcing the OTEL backend. Raises
95
- # Riffer::ArgumentError when the OpenTelemetry API gem isn't available
96
- # at a supported version.
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 tracer_provider=(value)
100
- if !value.nil? && !Riffer::Tracing::Otel.available?
101
- raise Riffer::ArgumentError,
102
- "tracer_provider requires the opentelemetry-api gem (#{Riffer::Tracing::Otel::SUPPORTED_API_VERSIONS})"
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
- @tracer_provider = value
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
- # Explicit OTEL meter provider; defaults to +nil+, which resolves the
117
- # global <tt>OpenTelemetry.meter_provider</tt> at first record.
118
- attr_reader :meter_provider #: untyped
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
- @meter_provider = nil
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 an explicit meter provider, forcing the OTEL backend. Raises
137
- # Riffer::ArgumentError when the OpenTelemetry metrics API gem isn't
138
- # available at a supported version.
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 meter_provider=(value)
142
- if !value.nil? && !Riffer::Metrics::Otel.available?
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
- @meter_provider = value
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
- Riffer::Tracing.in_span("execute_guardrail #{guardrail.name}", attributes: guardrail_span_attributes(guardrail), kind: :internal) do |span|
85
- result = run_guardrail_phase(guardrail, data, messages: messages)
86
- record_guardrail_outcome(span, result)
87
- result
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
- # The backend records the exception and error status on the re-raise;
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::Null::Span), Riffer::Guardrails::Result) -> void
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::Null # :nodoc: all
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.
@@ -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 Null.
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
 
@@ -7,7 +7,7 @@
7
7
  module Riffer::Metrics # :nodoc: all
8
8
  extend self
9
9
 
10
- # @rbs @backend: (Riffer::Metrics::Otel | singleton(Riffer::Metrics::Null))?
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.is_a?(Otel)
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
- #: () -> (Riffer::Metrics::Otel | singleton(Riffer::Metrics::Null))
84
+ #: () -> untyped
84
85
  def backend
85
86
  @backend || MUTEX.synchronize { @backend ||= resolve_backend }
86
87
  end
87
88
 
88
89
  #--
89
- #: () -> (Riffer::Metrics::Otel | singleton(Riffer::Metrics::Null))
90
+ #: () -> untyped
90
91
  def resolve_backend
91
- Otel.build(provider: Riffer.config.metrics.meter_provider) || Null
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
- # caller's trace.
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::Null::Span) -> R } -> R
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::Null::Span), Symbol?, String?) -> void
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::Null::Span), Riffer::Tracing::StreamRecorder) -> void
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::Null::Span), Array[Riffer::Messages::Base]) -> void
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::Null::Span), content: String?, tool_calls: Array[Riffer::Messages::Assistant::ToolCall], finish_reason: Symbol?) -> void
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::Null::Span)) -> bool
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
- generation_config = options.except(:tools, :structured_output)
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