Skip to content

Commit 7511fe9

Browse files
authored
feat: Implement the AIClient and AITracker classes (#1)
1 parent 4080e20 commit 7511fe9

16 files changed

+1332
-20
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ doc/
77
/pkg/
88
/spec/reports/
99
/tmp/
10-
10+
.DS_Store
1111
# rspec failure tracking
1212
.rspec_status
1313

.rspec

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
--format documentation
2+
--color
3+
--require spec_helper

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ The output will appear in `docs/build/html`.
4343

4444
## Code organization
4545

46-
A special case is the namespace `LaunchDarkly::AI::Impl`, and any namespaces within it. Everything under `Impl` is considered a private implementation detail: all files there are excluded from the generated documentation, and are considered subject to change at any time and not supported for direct use by application developers. We do this because Ruby's scope/visibility system is somewhat limited compared to other languages: a method can be `private` or `protected` within a class, but there is no way to make it visible to other classes in the library yet invisible to code outside of the library, and there is similarly no way to hide a class.
46+
A special case is the namespace `LaunchDarkly::Server::AI::Impl`, and any namespaces within it. Everything under `Impl` is considered a private implementation detail: all files there are excluded from the generated documentation, and are considered subject to change at any time and not supported for direct use by application developers. We do this because Ruby's scope/visibility system is somewhat limited compared to other languages: a method can be `private` or `protected` within a class, but there is no way to make it visible to other classes in the library yet invisible to code outside of the library, and there is similarly no way to hide a class.
4747

4848
So, if there is a class whose existence is entirely an implementation detail, it should be in `Impl`. Similarly, classes that are _not_ in `Impl` must not expose any public members that are not meant to be part of the supported public API. This is important because of our guarantee of backward compatibility for all public APIs within a major version: we want to be able to change our implementation details to suit the needs of the code, without worrying about breaking a customer's code. Due to how the language works, we can't actually prevent an application developer from referencing those classes in their code, but this convention makes it clear that such use is discouraged and unsupported.
4949

5050
## Documenting types and methods
5151

52-
All classes and public methods outside of `LaunchDarkly::AI::Impl` should have documentation comments. These are used to build the API documentation that is published at https://launchdarkly.github.io/ruby-server-sdk-ai/ and https://www.rubydoc.info/gems/launchdarkly-server-sdk-ai. The documentation generator is YARD; see https://yardoc.org/ for the comment format it uses.
52+
All classes and public methods outside of `LaunchDarkly::Server::AI::Impl` should have documentation comments. These are used to build the API documentation that is published at https://launchdarkly.github.io/ruby-server-sdk-ai/ and https://www.rubydoc.info/gems/launchdarkly-server-sdk-ai. The documentation generator is YARD; see https://yardoc.org/ for the comment format it uses.
5353

5454
Please try to make the style and terminology in documentation comments consistent with other documentation comments in the library. Also, if a class or method is being added that has an equivalent in other libraries, and if we have described it in a consistent away in those other libraries, please reuse the text whenever possible (with adjustments for anything language-specific) rather than writing new text.

Gemfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
3+
source 'https://rubygems.org'
4+
5+
# Specify your gem's dependencies in launchdarkly-server-sdk-ai.gemspec
6+
gemspec

bin/console

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require 'bundler/setup'
5+
require 'launchdarkly_server_sdk_ai'
6+
7+
# You can add fixtures and/or initialization code here to make experimenting
8+
# with your gem easier. You can also use a different console, if you like.
9+
10+
require 'irb'
11+
IRB.start(__FILE__)

bin/setup

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
IFS=$'\n\t'
4+
set -vx
5+
6+
bundle install
7+
8+
# Do any other automated setup that you need to do here

examples/hello-bedrock/Gemfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
3+
source 'https://rubygems.org'
4+
5+
gem 'aws-sdk-bedrockruntime'
6+
gem 'launchdarkly-server-sdk-ai', path: '../../..'

launchdarkly-server-sdk-ai.gemspec

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
11
# frozen_string_literal: true
22

3-
require_relative "lib/ldclient-ai/version"
3+
require_relative 'lib/server/ai/version'
44

55
Gem::Specification.new do |spec|
6-
spec.name = "launchdarkly-server-sdk-ai"
7-
spec.version = LaunchDarkly::AI::VERSION
8-
spec.authors = ["LaunchDarkly"]
9-
spec.email = ["[email protected]"]
10-
spec.summary = "LaunchDarkly AI SDK for Ruby"
11-
spec.description = "LaunchDarkly SDK AI Configs integration for the Ruby server side SDK"
12-
spec.license = "Apache-2.0"
13-
spec.homepage = "https://github.com/launchdarkly/ruby-server-sdk-ai"
14-
spec.metadata["source_code_uri"] = "https://github.com/launchdarkly/ruby-server-sdk-ai"
15-
spec.metadata["changelog_uri"] = "https://github.com/launchdarkly/ruby-server-sdk-ai/blob/main/CHANGELOG.md"
6+
spec.name = 'launchdarkly-server-sdk-ai'
7+
spec.version = LaunchDarkly::Server::AI::VERSION
8+
spec.authors = ['LaunchDarkly']
9+
spec.email = ['[email protected]']
10+
spec.summary = 'LaunchDarkly AI SDK for Ruby'
11+
spec.description = 'LaunchDarkly SDK AI Configs integration for the Ruby server side SDK'
12+
spec.license = 'Apache-2.0'
13+
spec.homepage = 'https://github.com/launchdarkly/ruby-server-sdk-ai'
14+
spec.metadata['source_code_uri'] = 'https://github.com/launchdarkly/ruby-server-sdk-ai'
15+
spec.metadata['changelog_uri'] = 'https://github.com/launchdarkly/ruby-server-sdk-ai/blob/main/CHANGELOG.md'
1616

17-
spec.files = Dir["{lib}/**/*.rb", "bin/*", "LICENSE", "*.md"]
17+
spec.files = Dir['{lib}/**/*.rb', 'bin/*', 'LICENSE', '*.md']
1818
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19-
spec.require_paths = ["lib"]
20-
spec.required_ruby_version = ">= 3.0.0"
19+
spec.require_paths = ['lib']
20+
spec.required_ruby_version = '>= 3.0.0'
2121

22-
spec.add_runtime_dependency "launchdarkly-server-sdk", "~> 8.4.0"
23-
end
22+
spec.add_dependency 'launchdarkly-server-sdk', '~> 8.5'
23+
spec.add_dependency 'logger'
24+
spec.add_dependency 'mustache', '~> 1.1'
25+
26+
spec.add_development_dependency 'bundler', '~> 2.0'
27+
spec.add_development_dependency 'debug', '~> 1.0'
28+
spec.add_development_dependency 'rake', '~> 13.0'
29+
spec.add_development_dependency 'rspec', '~> 3.0'
30+
spec.add_development_dependency 'rubocop', '~> 1.21'
31+
spec.add_development_dependency 'rubocop-performance', '~> 1.15'
32+
spec.add_development_dependency 'rubocop-rake', '~> 0.6'
33+
spec.add_development_dependency 'rubocop-rspec', '~> 3.6'
34+
end

lib/launchdarkly-server-sdk-ai.rb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
11
# frozen_string_literal: true
22

3-
raise "Reserved for LaunchDarkly"
3+
require 'logger'
4+
require 'mustache'
5+
6+
require 'server/ai/version'
7+
require 'server/ai/client'
8+
require 'server/ai/ai_config_tracker'
9+
10+
module LaunchDarkly
11+
module Server
12+
#
13+
# Namespace for the LaunchDarkly AI SDK.
14+
#
15+
module AI
16+
#
17+
# @return [Logger] the Rails logger if in Rails, or a default Logger at WARN level otherwise
18+
#
19+
def self.default_logger
20+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
21+
Rails.logger
22+
else
23+
log = ::Logger.new($stdout)
24+
log.level = ::Logger::WARN
25+
log
26+
end
27+
end
28+
end
29+
end
30+
end

lib/server/ai/ai_config_tracker.rb

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# frozen_string_literal: true
2+
3+
require 'ldclient-rb'
4+
5+
module LaunchDarkly
6+
module Server
7+
module AI
8+
#
9+
# Tracks token usage for AI operations.
10+
#
11+
class TokenUsage
12+
attr_reader :total, :input, :output
13+
14+
#
15+
# @param total [Integer] Total number of tokens used.
16+
# @param input [Integer] Number of tokens in the prompt.
17+
# @param output [Integer] Number of tokens in the completion.
18+
#
19+
def initialize(total: nil, input: nil, output: nil)
20+
@total = total
21+
@input = input
22+
@output = output
23+
end
24+
end
25+
26+
#
27+
# Summary of metrics which have been tracked.
28+
#
29+
class MetricSummary
30+
attr_accessor :duration, :success, :feedback, :usage, :time_to_first_token
31+
32+
def initialize
33+
@duration = nil
34+
@success = nil
35+
@feedback = nil
36+
@usage = nil
37+
@time_to_first_token = nil
38+
end
39+
end
40+
41+
#
42+
# The AIConfigTracker class is used to track AI configuration usage.
43+
#
44+
class AIConfigTracker
45+
attr_reader :ld_client, :config_key, :context, :variation_key, :version, :summary
46+
47+
def initialize(ld_client:, variation_key:, config_key:, version:, context:)
48+
@ld_client = ld_client
49+
@variation_key = variation_key
50+
@config_key = config_key
51+
@version = version
52+
@context = context
53+
@summary = MetricSummary.new
54+
end
55+
56+
#
57+
# Track the duration of an AI operation
58+
#
59+
# @param duration [Integer] The duration in milliseconds
60+
#
61+
def track_duration(duration)
62+
@summary.duration = duration
63+
@ld_client.track(
64+
'$ld:ai:duration:total',
65+
@context,
66+
flag_data,
67+
duration
68+
)
69+
end
70+
71+
#
72+
# Track the duration of a block of code
73+
#
74+
# @yield The block to measure
75+
# @return The result of the block
76+
#
77+
def track_duration_of(&block)
78+
start_time = Time.now
79+
yield
80+
ensure
81+
duration = ((Time.now - start_time) * 1000).to_i
82+
track_duration(duration)
83+
end
84+
85+
#
86+
# Track time to first token
87+
#
88+
# @param duration [Integer] The duration in milliseconds
89+
#
90+
def track_time_to_first_token(time_to_first_token)
91+
@summary.time_to_first_token = time_to_first_token
92+
@ld_client.track(
93+
'$ld:ai:tokens:ttf',
94+
@context,
95+
flag_data,
96+
time_to_first_token
97+
)
98+
end
99+
100+
#
101+
# Track user feedback
102+
#
103+
# @param kind [Symbol] The kind of feedback (:positive or :negative)
104+
#
105+
def track_feedback(kind:)
106+
@summary.feedback = kind
107+
event_name = kind == :positive ? '$ld:ai:feedback:user:positive' : '$ld:ai:feedback:user:negative'
108+
@ld_client.track(
109+
event_name,
110+
@context,
111+
flag_data,
112+
1
113+
)
114+
end
115+
116+
#
117+
# Track a successful AI generation
118+
#
119+
def track_success
120+
@summary.success = true
121+
@ld_client.track(
122+
'$ld:ai:generation',
123+
@context,
124+
flag_data,
125+
1
126+
)
127+
@ld_client.track(
128+
'$ld:ai:generation:success',
129+
@context,
130+
flag_data,
131+
1
132+
)
133+
end
134+
135+
#
136+
# Track an error in AI generation
137+
#
138+
def track_error
139+
@summary.success = false
140+
@ld_client.track(
141+
'$ld:ai:generation',
142+
@context,
143+
flag_data,
144+
1
145+
)
146+
@ld_client.track(
147+
'$ld:ai:generation:error',
148+
@context,
149+
flag_data,
150+
1
151+
)
152+
end
153+
154+
#
155+
# Track token usage
156+
#
157+
# @param token_usage [TokenUsage] An object containing token usage details
158+
#
159+
def track_tokens(token_usage)
160+
@summary.usage = token_usage
161+
if token_usage.total.positive?
162+
@ld_client.track(
163+
'$ld:ai:tokens:total',
164+
@context,
165+
flag_data,
166+
token_usage.total
167+
)
168+
end
169+
if token_usage.input.positive?
170+
@ld_client.track(
171+
'$ld:ai:tokens:input',
172+
@context,
173+
flag_data,
174+
token_usage.input
175+
)
176+
end
177+
return unless token_usage.output.positive?
178+
179+
@ld_client.track(
180+
'$ld:ai:tokens:output',
181+
@context,
182+
flag_data,
183+
token_usage.output
184+
)
185+
end
186+
187+
#
188+
# Track OpenAI-specific operations.
189+
# This method tracks the duration, token usage, and success/error status.
190+
# If the provided block raises, this method will also raise.
191+
# A failed operation will not have any token usage data.
192+
#
193+
# @yield The block to track.
194+
# @return The result of the tracked block.
195+
#
196+
def track_openai_metrics(&block)
197+
result = track_duration_of(&block)
198+
track_success
199+
track_tokens(openai_to_token_usage(result[:usage])) if result[:usage]
200+
result
201+
rescue StandardError
202+
track_error
203+
raise
204+
end
205+
206+
#
207+
# Track AWS Bedrock conversation operations.
208+
# This method tracks the duration, token usage, and success/error status.
209+
#
210+
# @yield The block to track.
211+
# @return [Hash] The original response hash.
212+
#
213+
def track_bedrock_converse_metrics(&block)
214+
result = track_duration_of(&block)
215+
track_success
216+
track_tokens(bedrock_to_token_usage(result[:usage])) if result[:usage]
217+
result
218+
rescue StandardError
219+
track_error
220+
raise
221+
end
222+
223+
private def flag_data
224+
{ variationKey: @variation_key, configKey: @config_key, version: @version }
225+
end
226+
227+
private def openai_to_token_usage(usage)
228+
TokenUsage.new(
229+
total: usage[:total_tokens] || usage['total_tokens'],
230+
input: usage[:prompt_tokens] || usage['prompt_tokens'],
231+
output: usage[:completion_tokens] || usage['completion_tokens']
232+
)
233+
end
234+
235+
private def bedrock_to_token_usage(usage)
236+
TokenUsage.new(
237+
total: usage[:total_tokens] || usage['total_tokens'],
238+
input: usage[:input_tokens] || usage['input_tokens'],
239+
output: usage[:output_tokens] || usage['output_tokens']
240+
)
241+
end
242+
end
243+
end
244+
end
245+
end

0 commit comments

Comments
 (0)