langfuse-rb 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -1
- data/README.md +9 -63
- data/lib/langfuse/api_client.rb +344 -30
- data/lib/langfuse/cache_constants.rb +32 -0
- data/lib/langfuse/chat_prompt_client.rb +135 -20
- data/lib/langfuse/client.rb +186 -25
- data/lib/langfuse/config.rb +13 -2
- data/lib/langfuse/otel_attributes.rb +12 -4
- data/lib/langfuse/prompt_cache.rb +142 -7
- data/lib/langfuse/prompt_cache_events.rb +110 -0
- data/lib/langfuse/prompt_fetch_result.rb +122 -0
- data/lib/langfuse/prompt_renderer.rb +18 -0
- data/lib/langfuse/rails_cache_adapter.rb +161 -9
- data/lib/langfuse/stale_while_revalidate.rb +62 -19
- data/lib/langfuse/text_prompt_client.rb +21 -3
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +4 -0
- metadata +6 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "prompt_renderer"
|
|
4
4
|
|
|
5
5
|
module Langfuse
|
|
6
6
|
# Chat prompt client for compiling chat prompts with variable substitution
|
|
@@ -20,6 +20,8 @@ module Langfuse
|
|
|
20
20
|
# chat_prompt.labels # => ["production"]
|
|
21
21
|
#
|
|
22
22
|
class ChatPromptClient
|
|
23
|
+
PLACEHOLDER_TYPE = "placeholder"
|
|
24
|
+
|
|
23
25
|
# @return [String] Prompt name
|
|
24
26
|
attr_reader :name
|
|
25
27
|
|
|
@@ -35,14 +37,24 @@ module Langfuse
|
|
|
35
37
|
# @return [Hash] Prompt configuration
|
|
36
38
|
attr_reader :config
|
|
37
39
|
|
|
38
|
-
# @return [Array<Hash>] Array of message hashes
|
|
40
|
+
# @return [Array<Hash>] Array of message hashes and placeholder entries
|
|
39
41
|
attr_reader :prompt
|
|
40
42
|
|
|
43
|
+
# @return [String, nil] Optional commit message for this prompt version
|
|
44
|
+
attr_reader :commit_message
|
|
45
|
+
|
|
46
|
+
# @return [Hash, nil] Optional dependency resolution graph for composed prompts
|
|
47
|
+
attr_reader :resolution_graph
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] Whether this client uses caller-provided fallback content
|
|
50
|
+
attr_reader :is_fallback
|
|
51
|
+
|
|
41
52
|
# Initialize a new chat prompt client
|
|
42
53
|
#
|
|
43
54
|
# @param prompt_data [Hash] The prompt data from the API
|
|
55
|
+
# @param is_fallback [Boolean] Whether this client wraps caller-provided fallback content
|
|
44
56
|
# @raise [ArgumentError] if prompt data is invalid
|
|
45
|
-
def initialize(prompt_data)
|
|
57
|
+
def initialize(prompt_data, is_fallback: false)
|
|
46
58
|
validate_prompt_data!(prompt_data)
|
|
47
59
|
|
|
48
60
|
@name = prompt_data["name"]
|
|
@@ -51,16 +63,27 @@ module Langfuse
|
|
|
51
63
|
@labels = prompt_data["labels"] || []
|
|
52
64
|
@tags = prompt_data["tags"] || []
|
|
53
65
|
@config = prompt_data["config"] || {}
|
|
66
|
+
@commit_message = prompt_data["commitMessage"]
|
|
67
|
+
@resolution_graph = prompt_data["resolutionGraph"]
|
|
68
|
+
@is_fallback = is_fallback
|
|
54
69
|
end
|
|
55
70
|
|
|
56
|
-
#
|
|
71
|
+
# @return [String] Prompt type ("chat")
|
|
72
|
+
def type
|
|
73
|
+
"chat"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Compile the chat prompt with variable substitution and message placeholders
|
|
57
77
|
#
|
|
58
78
|
# Returns an array of message hashes with roles and compiled content.
|
|
59
|
-
#
|
|
60
|
-
#
|
|
79
|
+
# Placeholder entries are resolved from keyword arguments: arrays are
|
|
80
|
+
# expanded, empty arrays are skipped, unresolved placeholders stay in the
|
|
81
|
+
# output, and malformed values raise before invalid messages are sent to
|
|
82
|
+
# an LLM provider.
|
|
61
83
|
#
|
|
62
|
-
# @param kwargs [Hash] Variables
|
|
63
|
-
# @return [Array<Hash>] Array of compiled messages
|
|
84
|
+
# @param kwargs [Hash] Variables and placeholder values to compile
|
|
85
|
+
# @return [Array<Hash>] Array of compiled messages and unresolved placeholders
|
|
86
|
+
# @raise [ArgumentError] if a placeholder value is malformed
|
|
64
87
|
#
|
|
65
88
|
# @example
|
|
66
89
|
# chat_prompt.compile(name: "Alice", topic: "Ruby")
|
|
@@ -69,9 +92,18 @@ module Langfuse
|
|
|
69
92
|
# # { role: :user, content: "Hello Alice, let's discuss Ruby!" }
|
|
70
93
|
# # ]
|
|
71
94
|
def compile(**kwargs)
|
|
72
|
-
|
|
73
|
-
|
|
95
|
+
unresolved = []
|
|
96
|
+
compiled = []
|
|
97
|
+
prompt.each do |message|
|
|
98
|
+
normalized = symbolize_keys(message)
|
|
99
|
+
if normalized[:type].to_s == PLACEHOLDER_TYPE
|
|
100
|
+
append_placeholder(normalized, kwargs, compiled, unresolved)
|
|
101
|
+
else
|
|
102
|
+
compiled << compile_message(normalized, kwargs)
|
|
103
|
+
end
|
|
74
104
|
end
|
|
105
|
+
warn_unresolved(unresolved)
|
|
106
|
+
compiled
|
|
75
107
|
end
|
|
76
108
|
|
|
77
109
|
private
|
|
@@ -88,19 +120,102 @@ module Langfuse
|
|
|
88
120
|
raise ArgumentError, "prompt must be an Array" unless prompt_data["prompt"].is_a?(Array)
|
|
89
121
|
end
|
|
90
122
|
|
|
91
|
-
# Compile a single message with variable substitution
|
|
123
|
+
# Compile a single role/content message with variable substitution
|
|
92
124
|
#
|
|
93
|
-
# @param
|
|
125
|
+
# @param normalized [Hash] Symbolized message hash
|
|
94
126
|
# @param variables [Hash] Variables to substitute
|
|
95
127
|
# @return [Hash] Compiled message with :role and :content as symbols
|
|
96
|
-
def compile_message(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
def compile_message(normalized, variables)
|
|
129
|
+
normalized.except(:type).merge(
|
|
130
|
+
role: normalize_role(normalized[:role]),
|
|
131
|
+
content: render(normalized[:content] || "", variables)
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @api private
|
|
136
|
+
def append_placeholder(message, variables, compiled, unresolved)
|
|
137
|
+
name = message[:name].to_s
|
|
138
|
+
found, value = lookup_placeholder(variables, name)
|
|
139
|
+
return append_unresolved(name, compiled, unresolved) unless found
|
|
140
|
+
|
|
141
|
+
expand_placeholder(name, value, variables, compiled)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @api private
|
|
145
|
+
def append_unresolved(name, compiled, unresolved)
|
|
146
|
+
unresolved << name
|
|
147
|
+
compiled << { type: PLACEHOLDER_TYPE, name: name }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @api private
|
|
151
|
+
def expand_placeholder(name, value, variables, compiled)
|
|
152
|
+
return if value.is_a?(Array) && value.empty?
|
|
153
|
+
|
|
154
|
+
unless value.is_a?(Array)
|
|
155
|
+
raise ArgumentError, "Placeholder '#{name}' must contain an array of chat message hashes, got #{value.class}."
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
value.each { |entry| compiled << placeholder_message(entry, variables, name) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @api private
|
|
162
|
+
def lookup_placeholder(variables, name)
|
|
163
|
+
return [true, variables[name.to_sym]] if variables.key?(name.to_sym)
|
|
164
|
+
return [true, variables[name]] if variables.key?(name)
|
|
165
|
+
|
|
166
|
+
[false, nil]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @api private
|
|
170
|
+
def placeholder_message(message, variables, name)
|
|
171
|
+
unless message.is_a?(Hash)
|
|
172
|
+
raise ArgumentError,
|
|
173
|
+
"Placeholder '#{name}' must contain an array of chat message hashes with role and content fields."
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
normalized = symbolize_keys(message)
|
|
177
|
+
unless valid_placeholder_message?(normalized)
|
|
178
|
+
raise ArgumentError,
|
|
179
|
+
"Placeholder '#{name}' must contain an array of chat message hashes with role and content fields."
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
normalized.merge(
|
|
183
|
+
role: normalize_role(normalized[:role]),
|
|
184
|
+
content: render(normalized[:content] || "", variables)
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @api private
|
|
189
|
+
def render(content, variables)
|
|
190
|
+
variables.empty? ? content : PromptRenderer.render(content, variables)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @api private
|
|
194
|
+
def valid_placeholder_message?(message)
|
|
195
|
+
message.is_a?(Hash) &&
|
|
196
|
+
message.key?(:role) &&
|
|
197
|
+
!message[:role].to_s.empty? &&
|
|
198
|
+
message.key?(:content)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @api private
|
|
202
|
+
def warn_unresolved(names)
|
|
203
|
+
return if names.empty?
|
|
204
|
+
|
|
205
|
+
unresolved_names = names.uniq.sort
|
|
206
|
+
message = "Placeholders #{unresolved_names.inspect} have not been resolved. " \
|
|
207
|
+
"Pass them as keyword arguments to compile()."
|
|
208
|
+
warn_msg(message)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @api private
|
|
212
|
+
def warn_msg(message)
|
|
213
|
+
Langfuse.configuration.logger.warn("Langfuse: #{message}")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @api private
|
|
217
|
+
def symbolize_keys(hash)
|
|
218
|
+
hash.transform_keys(&:to_sym)
|
|
104
219
|
end
|
|
105
220
|
|
|
106
221
|
# Normalize role to symbol
|
data/lib/langfuse/client.rb
CHANGED
|
@@ -46,7 +46,8 @@ module Langfuse
|
|
|
46
46
|
base_url: config.base_url,
|
|
47
47
|
timeout: config.timeout,
|
|
48
48
|
logger: config.logger,
|
|
49
|
-
cache: cache
|
|
49
|
+
cache: cache,
|
|
50
|
+
cache_observer: config.prompt_cache_observer
|
|
50
51
|
)
|
|
51
52
|
|
|
52
53
|
@project_id = nil
|
|
@@ -68,6 +69,7 @@ module Langfuse
|
|
|
68
69
|
# @param label [String, nil] Optional label (e.g., "production", "latest")
|
|
69
70
|
# @param fallback [String, Array, nil] Optional fallback prompt to use on error
|
|
70
71
|
# @param type [Symbol, nil] Required when fallback is provided (:text or :chat)
|
|
72
|
+
# @param cache_ttl [Integer, nil] Optional TTL override for this fetch
|
|
71
73
|
# @return [TextPromptClient, ChatPromptClient] The prompt client
|
|
72
74
|
# @raise [ArgumentError] if both version and label are provided
|
|
73
75
|
# @raise [ArgumentError] if fallback is provided without type
|
|
@@ -77,24 +79,114 @@ module Langfuse
|
|
|
77
79
|
#
|
|
78
80
|
# @example With fallback for graceful degradation
|
|
79
81
|
# prompt = client.get_prompt("greeting", fallback: "Hello {{name}}!", type: :text)
|
|
80
|
-
def get_prompt(name, version: nil, label: nil, fallback: nil, type: nil)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
def get_prompt(name, version: nil, label: nil, fallback: nil, type: nil, cache_ttl: nil)
|
|
83
|
+
get_prompt_result(
|
|
84
|
+
name,
|
|
85
|
+
version: version,
|
|
86
|
+
label: label,
|
|
87
|
+
fallback: fallback,
|
|
88
|
+
type: type,
|
|
89
|
+
cache_ttl: cache_ttl
|
|
90
|
+
).prompt
|
|
91
|
+
end
|
|
85
92
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
# Fetch a prompt and return cache metadata.
|
|
94
|
+
#
|
|
95
|
+
# @param name [String] The name of the prompt
|
|
96
|
+
# @param version [Integer, nil] Optional specific version number
|
|
97
|
+
# @param label [String, nil] Optional label (e.g., "production", "latest")
|
|
98
|
+
# @param fallback [String, Array, nil] Optional fallback prompt to use on error
|
|
99
|
+
# @param type [Symbol, nil] Required when fallback is provided (:text or :chat)
|
|
100
|
+
# @param cache_ttl [Integer, nil] Optional TTL override for this fetch
|
|
101
|
+
# @return [PromptFetchResult] Prompt client plus cache metadata
|
|
102
|
+
# @raise [ArgumentError] if fallback is provided without type
|
|
103
|
+
# @raise [NotFoundError] if the prompt is not found and no fallback provided
|
|
104
|
+
# @raise [UnauthorizedError] if authentication fails and no fallback provided
|
|
105
|
+
# @raise [ApiError] for other API errors and no fallback provided
|
|
106
|
+
def get_prompt_result(name, version: nil, label: nil, fallback: nil, type: nil, cache_ttl: nil)
|
|
107
|
+
validate_fallback_usage!(fallback, type)
|
|
108
|
+
|
|
109
|
+
api_result = api_client.get_prompt_result(name, version: version, label: label, cache_ttl: cache_ttl)
|
|
110
|
+
build_client_fetch_result(api_result, build_prompt_client(api_result.prompt))
|
|
89
111
|
rescue ApiError, NotFoundError, UnauthorizedError => e
|
|
90
112
|
# If no fallback, re-raise the error
|
|
91
113
|
raise e unless fallback
|
|
92
114
|
|
|
93
115
|
# Log warning and return fallback
|
|
94
116
|
config.logger.warn("Langfuse API error for prompt '#{name}': #{e.message}. Using fallback.")
|
|
95
|
-
|
|
117
|
+
key = api_client.prompt_cache_key(name, version: version, label: label)
|
|
118
|
+
build_fallback_prompt_result(key, fallback: fallback, type: type, cache_ttl: cache_ttl, error: e)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Refresh a prompt from the API, optionally writing through to cache.
|
|
122
|
+
#
|
|
123
|
+
# @param name [String] The name of the prompt
|
|
124
|
+
# @param version [Integer, nil] Optional specific version number
|
|
125
|
+
# @param label [String, nil] Optional label (e.g., "production", "latest")
|
|
126
|
+
# @param cache_ttl [Integer, nil] Optional TTL override for this refresh
|
|
127
|
+
# @return [PromptFetchResult] Prompt client plus cache metadata
|
|
128
|
+
# @raise [ArgumentError] if both version and label are provided
|
|
129
|
+
# @raise [NotFoundError] if the prompt is not found
|
|
130
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
131
|
+
# @raise [ApiError] for other API errors
|
|
132
|
+
def refresh_prompt(name, version: nil, label: nil, cache_ttl: nil)
|
|
133
|
+
api_result = api_client.refresh_prompt(name, version: version, label: label, cache_ttl: cache_ttl)
|
|
134
|
+
build_client_fetch_result(api_result, build_prompt_client(api_result.prompt))
|
|
96
135
|
end
|
|
97
136
|
|
|
137
|
+
# Invalidate one exact logical prompt cache key.
|
|
138
|
+
#
|
|
139
|
+
# @param name [String] The prompt name
|
|
140
|
+
# @param version [Integer, nil] Optional specific version number
|
|
141
|
+
# @param label [String, nil] Optional label
|
|
142
|
+
# @return [PromptCacheKey] The invalidated key
|
|
143
|
+
def invalidate_prompt_cache(name, version: nil, label: nil)
|
|
144
|
+
api_client.invalidate_prompt_cache(name, version: version, label: label)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Invalidate all cached variants for one prompt name.
|
|
148
|
+
#
|
|
149
|
+
# @param name [String] The prompt name
|
|
150
|
+
# @return [Integer, nil] New generation, or nil when cache is disabled
|
|
151
|
+
def invalidate_prompt_cache_by_name(name)
|
|
152
|
+
api_client.invalidate_prompt_cache_by_name(name)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Logically clear the whole Langfuse prompt cache namespace.
|
|
156
|
+
#
|
|
157
|
+
# @return [Integer, nil] New global generation, or nil when cache is disabled
|
|
158
|
+
def clear_prompt_cache
|
|
159
|
+
api_client.clear_prompt_cache
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Return prompt cache statistics.
|
|
163
|
+
#
|
|
164
|
+
# @return [Hash] Cache statistics
|
|
165
|
+
def prompt_cache_stats
|
|
166
|
+
api_client.prompt_cache_stats
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Inspect the logical and generated cache keys for a prompt.
|
|
170
|
+
#
|
|
171
|
+
# @param name [String] The prompt name
|
|
172
|
+
# @param version [Integer, nil] Optional specific version number
|
|
173
|
+
# @param label [String, nil] Optional label
|
|
174
|
+
# @return [PromptCacheKey] Logical and generated cache keys
|
|
175
|
+
def prompt_cache_key(name, version: nil, label: nil)
|
|
176
|
+
api_client.prompt_cache_key(name, version: version, label: label)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Validate the configured prompt cache backend before first prompt fetch.
|
|
180
|
+
#
|
|
181
|
+
# @return [Boolean] true when the configured backend is usable
|
|
182
|
+
# @raise [ConfigurationError] if the backend is invalid
|
|
183
|
+
# rubocop:disable Naming/PredicateMethod
|
|
184
|
+
def validate_prompt_cache_backend!
|
|
185
|
+
api_client.cache&.validate! if api_client.cache.respond_to?(:validate!)
|
|
186
|
+
true
|
|
187
|
+
end
|
|
188
|
+
# rubocop:enable Naming/PredicateMethod
|
|
189
|
+
|
|
98
190
|
# List all prompts in the Langfuse project
|
|
99
191
|
#
|
|
100
192
|
# Fetches a list of all prompt names available in your project.
|
|
@@ -126,6 +218,7 @@ module Langfuse
|
|
|
126
218
|
# @param label [String, nil] Optional label (e.g., "production", "latest")
|
|
127
219
|
# @param fallback [String, Array, nil] Optional fallback prompt to use on error
|
|
128
220
|
# @param type [Symbol, nil] Required when fallback is provided (:text or :chat)
|
|
221
|
+
# @param cache_ttl [Integer, nil] Optional TTL override for this fetch
|
|
129
222
|
# @return [String, Array<Hash>] Compiled prompt (String for text, Array for chat)
|
|
130
223
|
# @raise [ArgumentError] if both version and label are provided
|
|
131
224
|
# @raise [ArgumentError] if fallback is provided without type
|
|
@@ -148,10 +241,19 @@ module Langfuse
|
|
|
148
241
|
# fallback: "Hello {{name}}!",
|
|
149
242
|
# type: :text
|
|
150
243
|
# )
|
|
151
|
-
|
|
152
|
-
|
|
244
|
+
# rubocop:disable Metrics/ParameterLists
|
|
245
|
+
def compile_prompt(name, variables: {}, version: nil, label: nil, fallback: nil, type: nil, cache_ttl: nil)
|
|
246
|
+
prompt = get_prompt(
|
|
247
|
+
name,
|
|
248
|
+
version: version,
|
|
249
|
+
label: label,
|
|
250
|
+
fallback: fallback,
|
|
251
|
+
type: type,
|
|
252
|
+
cache_ttl: cache_ttl
|
|
253
|
+
)
|
|
153
254
|
prompt.compile(**variables)
|
|
154
255
|
end
|
|
256
|
+
# rubocop:enable Metrics/ParameterLists
|
|
155
257
|
|
|
156
258
|
# Create a new prompt (or new version if name already exists)
|
|
157
259
|
#
|
|
@@ -738,6 +840,48 @@ module Langfuse
|
|
|
738
840
|
list_dataset_items(dataset_name: dataset_name)
|
|
739
841
|
end
|
|
740
842
|
|
|
843
|
+
def validate_fallback_usage!(fallback, type)
|
|
844
|
+
return unless fallback && !type
|
|
845
|
+
|
|
846
|
+
raise ArgumentError, "type parameter is required when fallback is provided (use :text or :chat)"
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
def build_client_fetch_result(api_result, prompt_client)
|
|
850
|
+
PromptFetchResult.new(
|
|
851
|
+
prompt: prompt_client,
|
|
852
|
+
logical_key: api_result.logical_key,
|
|
853
|
+
storage_key: api_result.storage_key,
|
|
854
|
+
cache_status: api_result.cache_status,
|
|
855
|
+
source: api_result.source,
|
|
856
|
+
name: prompt_client.name,
|
|
857
|
+
version: prompt_client.version,
|
|
858
|
+
label: api_result.label
|
|
859
|
+
)
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def build_fallback_prompt_result(key, fallback:, type:, cache_ttl:, error:)
|
|
863
|
+
prompt_client = build_fallback_prompt_client(key.name, fallback, type)
|
|
864
|
+
cache_status = fallback_cache_status(cache_ttl)
|
|
865
|
+
api_client.emit_prompt_fallback_event(key, cache_status: cache_status, error: error)
|
|
866
|
+
PromptFetchResult.new(
|
|
867
|
+
prompt: prompt_client,
|
|
868
|
+
logical_key: key.logical_key,
|
|
869
|
+
storage_key: key.storage_key,
|
|
870
|
+
cache_status: cache_status,
|
|
871
|
+
source: CacheSource::FALLBACK,
|
|
872
|
+
name: key.name,
|
|
873
|
+
version: key.version || prompt_client.version,
|
|
874
|
+
label: key.resolved_label
|
|
875
|
+
)
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def fallback_cache_status(cache_ttl)
|
|
879
|
+
return CacheStatus::BYPASS if cache_ttl&.zero?
|
|
880
|
+
return CacheStatus::DISABLED unless api_client.cache
|
|
881
|
+
|
|
882
|
+
CacheStatus::MISS
|
|
883
|
+
end
|
|
884
|
+
|
|
741
885
|
# Check if caching is enabled in configuration
|
|
742
886
|
#
|
|
743
887
|
# @return [Boolean]
|
|
@@ -754,11 +898,17 @@ module Langfuse
|
|
|
754
898
|
create_memory_cache
|
|
755
899
|
when :rails
|
|
756
900
|
create_rails_cache_adapter
|
|
901
|
+
when :auto
|
|
902
|
+
rails_cache_available? ? create_rails_cache_adapter : create_memory_cache
|
|
757
903
|
else
|
|
758
904
|
raise ConfigurationError, "Unknown cache backend: #{config.cache_backend}"
|
|
759
905
|
end
|
|
760
906
|
end
|
|
761
907
|
|
|
908
|
+
def rails_cache_available?
|
|
909
|
+
defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
|
|
910
|
+
end
|
|
911
|
+
|
|
762
912
|
# Create in-memory cache with SWR support if enabled
|
|
763
913
|
#
|
|
764
914
|
# @return [PromptCache]
|
|
@@ -823,9 +973,9 @@ module Langfuse
|
|
|
823
973
|
|
|
824
974
|
case type
|
|
825
975
|
when :text
|
|
826
|
-
TextPromptClient.new(prompt_data)
|
|
976
|
+
TextPromptClient.new(prompt_data, is_fallback: true)
|
|
827
977
|
when :chat
|
|
828
|
-
ChatPromptClient.new(prompt_data)
|
|
978
|
+
ChatPromptClient.new(prompt_data, is_fallback: true)
|
|
829
979
|
end
|
|
830
980
|
end
|
|
831
981
|
|
|
@@ -856,7 +1006,8 @@ module Langfuse
|
|
|
856
1006
|
|
|
857
1007
|
# Normalize prompt content for API request
|
|
858
1008
|
#
|
|
859
|
-
# Converts Ruby symbol keys to string keys for chat messages
|
|
1009
|
+
# Converts Ruby symbol keys to string keys for chat messages and preserves
|
|
1010
|
+
# Langfuse message placeholder entries.
|
|
860
1011
|
#
|
|
861
1012
|
# @param prompt [String, Array] The prompt content
|
|
862
1013
|
# @param type [Symbol] The prompt type
|
|
@@ -866,18 +1017,28 @@ module Langfuse
|
|
|
866
1017
|
|
|
867
1018
|
# Normalize chat messages to use string keys
|
|
868
1019
|
prompt.map do |message|
|
|
869
|
-
|
|
870
|
-
normalized
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
k
|
|
874
|
-
end
|
|
875
|
-
{
|
|
876
|
-
"role" => normalized[:role]&.to_s,
|
|
877
|
-
"content" => normalized[:content]
|
|
878
|
-
}
|
|
1020
|
+
normalized = message.transform_keys(&:to_s)
|
|
1021
|
+
next placeholder_prompt_content(normalized) if normalized["type"] == ChatPromptClient::PLACEHOLDER_TYPE
|
|
1022
|
+
|
|
1023
|
+
normalize_chat_message_content(normalized)
|
|
879
1024
|
end
|
|
880
1025
|
end
|
|
1026
|
+
|
|
1027
|
+
# @api private
|
|
1028
|
+
def placeholder_prompt_content(message)
|
|
1029
|
+
{
|
|
1030
|
+
"type" => ChatPromptClient::PLACEHOLDER_TYPE,
|
|
1031
|
+
"name" => message["name"].to_s
|
|
1032
|
+
}
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# @api private
|
|
1036
|
+
def normalize_chat_message_content(message)
|
|
1037
|
+
message.merge(
|
|
1038
|
+
"role" => message["role"]&.to_s,
|
|
1039
|
+
"content" => message["content"]
|
|
1040
|
+
)
|
|
1041
|
+
end
|
|
881
1042
|
end
|
|
882
1043
|
# rubocop:enable Metrics/ClassLength
|
|
883
1044
|
end
|
data/lib/langfuse/config.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Langfuse
|
|
|
41
41
|
# @return [Integer] Maximum number of cached items
|
|
42
42
|
attr_accessor :cache_max_size
|
|
43
43
|
|
|
44
|
-
# @return [Symbol] Cache backend (:memory or :
|
|
44
|
+
# @return [Symbol] Cache backend (:memory, :rails, or :auto)
|
|
45
45
|
attr_accessor :cache_backend
|
|
46
46
|
|
|
47
47
|
# @return [Integer] Lock timeout in seconds for distributed cache stampede protection
|
|
@@ -57,6 +57,9 @@ module Langfuse
|
|
|
57
57
|
# @return [Integer] Number of background threads for cache refresh
|
|
58
58
|
attr_accessor :cache_refresh_threads
|
|
59
59
|
|
|
60
|
+
# @return [#call, nil] Observer called for prompt cache events
|
|
61
|
+
attr_accessor :prompt_cache_observer
|
|
62
|
+
|
|
60
63
|
# @return [Boolean] Use async processing for traces (requires ActiveJob)
|
|
61
64
|
attr_accessor :tracing_async
|
|
62
65
|
|
|
@@ -158,6 +161,7 @@ module Langfuse
|
|
|
158
161
|
@cache_stale_while_revalidate = DEFAULT_CACHE_STALE_WHILE_REVALIDATE
|
|
159
162
|
@cache_stale_ttl = 0 # Default to 0 (SWR disabled, entries expire immediately after TTL)
|
|
160
163
|
@cache_refresh_threads = DEFAULT_CACHE_REFRESH_THREADS
|
|
164
|
+
@prompt_cache_observer = nil
|
|
161
165
|
@tracing_async = DEFAULT_TRACING_ASYNC
|
|
162
166
|
@batch_size = DEFAULT_BATCH_SIZE
|
|
163
167
|
@flush_interval = DEFAULT_FLUSH_INTERVAL
|
|
@@ -189,6 +193,7 @@ module Langfuse
|
|
|
189
193
|
validate_swr_config!
|
|
190
194
|
|
|
191
195
|
validate_cache_backend!
|
|
196
|
+
validate_prompt_cache_observer!
|
|
192
197
|
validate_sample_rate!
|
|
193
198
|
validate_should_export_span!
|
|
194
199
|
validate_mask!
|
|
@@ -240,13 +245,19 @@ module Langfuse
|
|
|
240
245
|
end
|
|
241
246
|
|
|
242
247
|
def validate_cache_backend!
|
|
243
|
-
valid_backends = %i[memory rails]
|
|
248
|
+
valid_backends = %i[memory rails auto]
|
|
244
249
|
return if valid_backends.include?(cache_backend)
|
|
245
250
|
|
|
246
251
|
raise ConfigurationError,
|
|
247
252
|
"cache_backend must be one of #{valid_backends.inspect}, got #{cache_backend.inspect}"
|
|
248
253
|
end
|
|
249
254
|
|
|
255
|
+
def validate_prompt_cache_observer!
|
|
256
|
+
return if prompt_cache_observer.nil? || prompt_cache_observer.respond_to?(:call)
|
|
257
|
+
|
|
258
|
+
raise ConfigurationError, "prompt_cache_observer must respond to #call"
|
|
259
|
+
end
|
|
260
|
+
|
|
250
261
|
def validate_swr_config!
|
|
251
262
|
validate_swr_stale_ttl!
|
|
252
263
|
validate_refresh_threads!
|
|
@@ -302,11 +302,9 @@ module Langfuse
|
|
|
302
302
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
303
303
|
def self.add_prompt_attributes(otel_attributes, prompt)
|
|
304
304
|
return unless prompt
|
|
305
|
+
return if fallback_prompt?(prompt)
|
|
305
306
|
|
|
306
|
-
# Handle hash-like prompts
|
|
307
307
|
if prompt.is_a?(Hash) || prompt.respond_to?(:[])
|
|
308
|
-
return if prompt[:is_fallback] || prompt["is_fallback"]
|
|
309
|
-
|
|
310
308
|
otel_attributes[OBSERVATION_PROMPT_NAME] = prompt[:name] || prompt["name"]
|
|
311
309
|
otel_attributes[OBSERVATION_PROMPT_VERSION] = prompt[:version] || prompt["version"]
|
|
312
310
|
# Handle objects with name/version methods (already converted in Trace#generation)
|
|
@@ -315,6 +313,16 @@ module Langfuse
|
|
|
315
313
|
otel_attributes[OBSERVATION_PROMPT_VERSION] = prompt.version
|
|
316
314
|
end
|
|
317
315
|
end
|
|
316
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
317
|
+
|
|
318
|
+
# @api private
|
|
319
|
+
def self.fallback_prompt?(prompt)
|
|
320
|
+
return true if prompt.respond_to?(:is_fallback) && prompt.is_fallback
|
|
321
|
+
return false unless prompt.is_a?(Hash)
|
|
322
|
+
|
|
323
|
+
!!get_hash_value(prompt, :is_fallback)
|
|
324
|
+
end
|
|
325
|
+
private_class_method :fallback_prompt?
|
|
318
326
|
end
|
|
319
|
-
# rubocop:enable Metrics/
|
|
327
|
+
# rubocop:enable Metrics/ModuleLength
|
|
320
328
|
end
|