crystil 0.5.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +77 -0
  3. data/LICENSE +21 -0
  4. data/README.md +112 -0
  5. data/lib/crystil/api/base.rb +104 -0
  6. data/lib/crystil/api/invocation.rb +59 -0
  7. data/lib/crystil/api/sentinel.rb +11 -0
  8. data/lib/crystil/api/workflow.rb +34 -0
  9. data/lib/crystil/api/workflows.rb +39 -0
  10. data/lib/crystil/attribution.rb +66 -0
  11. data/lib/crystil/client.rb +95 -0
  12. data/lib/crystil/collector.rb +88 -0
  13. data/lib/crystil/config.rb +79 -0
  14. data/lib/crystil/errors.rb +35 -0
  15. data/lib/crystil/sentinel.rb +100 -0
  16. data/lib/crystil/version.rb +5 -0
  17. data/lib/crystil/wrappers/anthropic.rb +112 -0
  18. data/lib/crystil/wrappers/base.rb +259 -0
  19. data/lib/crystil/wrappers/constants.rb +12 -0
  20. data/lib/crystil/wrappers/geminiai.rb +173 -0
  21. data/lib/crystil/wrappers/google.rb +133 -0
  22. data/lib/crystil/wrappers/groq.rb +295 -0
  23. data/lib/crystil/wrappers/openai.rb +249 -0
  24. data/lib/crystil/wrappers/ruby_llm.rb +170 -0
  25. data/lib/crystil.rb +52 -0
  26. data/sig/crystil/api/base.rbs +24 -0
  27. data/sig/crystil/api/invocation.rbs +16 -0
  28. data/sig/crystil/api/sentinel.rbs +9 -0
  29. data/sig/crystil/api/workflow.rbs +15 -0
  30. data/sig/crystil/api/workflows.rbs +16 -0
  31. data/sig/crystil/attribution.rbs +17 -0
  32. data/sig/crystil/client.rbs +29 -0
  33. data/sig/crystil/collector.rbs +20 -0
  34. data/sig/crystil/config.rbs +33 -0
  35. data/sig/crystil/errors.rbs +28 -0
  36. data/sig/crystil/sentinel.rbs +17 -0
  37. data/sig/crystil/version.rbs +5 -0
  38. data/sig/crystil/wrappers/anthropic.rbs +18 -0
  39. data/sig/crystil/wrappers/base.rbs +21 -0
  40. data/sig/crystil/wrappers/constants.rbs +10 -0
  41. data/sig/crystil/wrappers/geminiai.rbs +19 -0
  42. data/sig/crystil/wrappers/google.rbs +19 -0
  43. data/sig/crystil/wrappers/groq.rbs +22 -0
  44. data/sig/crystil/wrappers/openai.rbs +19 -0
  45. data/sig/crystil/wrappers/ruby_llm.rbs +21 -0
  46. metadata +100 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b4da070c2db6a9316560e3b603975352ab4fdd1e6335b9c2deae82e164f03dd3
4
+ data.tar.gz: 74444bd1e835403f51152bb6e6dbabf8317f33668498137c65562e6d72881371
5
+ SHA512:
6
+ metadata.gz: 3f6fdee56c16efdc5e5788ed3cce7714e666bff78e6d49514591e690f97dca8adab72c785d4d1ac2e8d34a308ca967096b907572d5623830f5da641723cc0d8f
7
+ data.tar.gz: 7b837bbb97bd54f4264b11bfe11b35b25da51225f929ce2b5d0d2e57e81c944ae273c38e64f75349c71478334bf1a221642f15882b4e3c9b3eb08414cbfec4b4
data/CHANGELOG.md ADDED
@@ -0,0 +1,77 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.0.1] - 2025-10-13
9
+
10
+ ### Added
11
+ - Initial release of Crystil Ruby SDK
12
+ - Support for OpenAI client tracking
13
+ - Support for Anthropic client tracking
14
+ - Support for Google GenAI client tracking
15
+ - Attribution tracking (parent/subsidiary hierarchy)
16
+ - Transaction management
17
+ - Asynchronous analytics collection
18
+ - Workflow API client
19
+ - Invocation API client
20
+ - Streaming response support
21
+ - Thread-safe configuration
22
+ - Comprehensive error handling
23
+
24
+ ## [0.0.2] - 2025-10-25
25
+
26
+ ### Fixed
27
+ - Corrected Crystil request payload structure as needed
28
+
29
+ ## [0.0.3] - 2025-10-27
30
+
31
+ ### Fixed
32
+ - Correct Crystil request payload structure for Google GenAI
33
+
34
+ ## [0.0.4] - 2025-10-28
35
+
36
+ ### Fixed
37
+ - Correct Crystil request payload structure for OpenAI streaming
38
+
39
+ ## [0.1.0] - 2026-02-03
40
+
41
+ ### Added
42
+ - Support for Sentinel intercept.
43
+ - Support for Gemini AI client tracking.
44
+
45
+ ## [0.1.1] - 2026-02-11
46
+
47
+ ### Fixed
48
+ - Stopped sending collector calls for intercepted requests
49
+
50
+ ## [0.2.0] - 2026-04-08
51
+
52
+ ### Added
53
+ - Support for the RubyLLM library
54
+
55
+ ## [0.3.0] - 2026-04-15
56
+
57
+ ### Added
58
+ - Support OpenAI Responses API (responses.create)
59
+
60
+ ## [0.4.0] - 2026-05-19
61
+
62
+ ### Added
63
+ - Support for the Groq Ruby client (`groq` gem, drnic/groq-ruby), including streaming.
64
+ - RBS type signatures shipped with the gem.
65
+
66
+ ## [0.5.0] - 2026-08-12
67
+
68
+ ### Changed
69
+ - **BREAKING**: Renamed Payloop to Crystil. Migrating from `payloop` 0.4.0 requires:
70
+ - Install the `crystil` gem in place of `payloop`; `require "payloop"` becomes `require "crystil"`.
71
+ - Replace the `Payloop::*` namespace with `Crystil::*` (e.g. `Payloop::Client` → `Crystil::Client`).
72
+ - Rename the `PAYLOOP_API_KEY`, `PAYLOOP_API_URL_BASE`, and `PAYLOOP_COLLECTOR_URL_BASE`
73
+ environment variables to `CRYSTIL_API_KEY`, `CRYSTIL_API_URL_BASE`, and
74
+ `CRYSTIL_COLLECTOR_URL_BASE`.
75
+ - Default endpoints now resolve to `api.crystil.com` and `collector.crystil.com`.
76
+
77
+ No API surface, method signature, or request payload changed — this release is a rename only.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CrystilAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # Welcome to Crystil!
2
+
3
+ ### Cost Visibility for AI Agents
4
+
5
+ Crystil is a lightweight infrastructure layer that gives AI teams real-time visibility into the true costs of deploying agents - across tasks, workflows, and customers. Most teams today can’t see what it actually costs to deploy their agents, making it nearly impossible to manage gross margins or price with confidence.
6
+
7
+ With just a single line of code, Crystil delivers:
8
+ - Cost tracking across OpenAI, Anthropic, Gemini, and more in one place
9
+ - Breakdowns by task, agent, and customer in real time
10
+ - Confidence to deploy the right pricing model (cost-plus, token-based, outcome-based, etc.) while preserving gross margins
11
+
12
+ Watch a short demo video: [Crystil Walkthrough Video](https://www.youtube.com/watch?v=Z-GkSl_7imY)
13
+
14
+ By surfacing exactly what’s driving cost and value, Crystil becomes the source of truth for agent economics - helping founders and operators scale their agents with confidence.
15
+
16
+ Sign up here: [crystil.com](https://crystil.com/)
17
+
18
+ # Installation
19
+
20
+ gem install crystil
21
+
22
+ # Documentation
23
+
24
+ Our SDK reference provides documentation for:
25
+ - Understanding how Crystil works
26
+ - Demonstrating how to integrate Crystil into your product
27
+ - Code samples to illustrate our SDK and API
28
+
29
+ Check it out here: [developers.crystil.com](https://developers.crystil.com)
30
+
31
+ # Releasing a New Version
32
+
33
+ Publishing is automated. Pushing a GitHub Release triggers
34
+ [`publish-to-rubygems.yaml`](.github/workflows/publish-to-rubygems.yaml), which
35
+ builds the gem and pushes it to RubyGems via Trusted Publishing (OIDC). There is
36
+ no API key stored in GitHub, and **you should never run `gem push` or
37
+ `rake release` by hand** — the release's safety checks only exist in CI, and a
38
+ version number can never be reused on RubyGems once taken.
39
+
40
+ To release version `X.Y.Z`:
41
+
42
+ 1. **Update the version number** in [lib/crystil/version.rb](lib/crystil/version.rb):
43
+ ```ruby
44
+ VERSION = "X.Y.Z"
45
+ ```
46
+ This constant is the single source of truth — the gemspec and the
47
+ `meta.sdk.version` field on every payload both read from it.
48
+
49
+ 2. **Update the CHANGELOG** in [CHANGELOG.md](CHANGELOG.md):
50
+ - Add a new section for the version with today's date
51
+ - Document all changes under appropriate categories (Added, Changed, Fixed, etc.)
52
+ - Follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format
53
+
54
+ 3. **Open a PR and merge it to `main`** once CI is green (lint, type-check, and
55
+ the Ruby 3.2 / 3.3 / 3.4 test matrix).
56
+
57
+ 4. **Publish a GitHub Release** tagged `vX.Y.Z` — the leading `v` is required,
58
+ and the rest must match `Crystil::VERSION` exactly or the workflow fails.
59
+ There is no separate `git tag` step: the release creates the tag against
60
+ `--target`, and pushing a tag on its own triggers nothing.
61
+ ```bash
62
+ gh release create vX.Y.Z --target main --title "vX.Y.Z" \
63
+ --notes "$(sed -n '/^## \[X.Y.Z\]/,/^## \[/p' CHANGELOG.md | sed '$d')"
64
+ ```
65
+ Pass the notes explicitly as above (or `--generate-notes` for an
66
+ auto-generated commit summary). Don't use `--notes-from-tag` here — when gh
67
+ creates the tag it is lightweight, so that flag falls back to the commit
68
+ message and your notes end up reading "Merge pull request #NN".
69
+
70
+ Or use the GitHub UI: *Releases → Draft a new release*, create the tag
71
+ `vX.Y.Z` against `main`, then **Publish release**. Saving a draft does not
72
+ trigger anything; only publishing does.
73
+
74
+ 5. **Watch the run**:
75
+ ```bash
76
+ gh run watch
77
+ ```
78
+ Before pushing anything, the workflow verifies the tag matches the version,
79
+ runs the unit and mocked integration specs, and asserts the packaged gem
80
+ contains what it should. It then waits for the new version to be installable
81
+ rather than merely uploaded, so a green run means the release is live.
82
+
83
+ 6. **Verify the release**:
84
+ - Check [rubygems.org/gems/crystil](https://rubygems.org/gems/crystil)
85
+ - Test installation: `gem install crystil -v X.Y.Z`
86
+
87
+ ## One-time publishing setup
88
+
89
+ The workflow authenticates via RubyGems Trusted Publishing, which must be
90
+ configured once before the first release.
91
+
92
+ Because `crystil` is not yet on RubyGems, create a **pending** publisher at
93
+ [rubygems.org/profile/oidc/pending_trusted_publishers](https://rubygems.org/profile/oidc/pending_trusted_publishers)
94
+ (for a gem that already exists, use *Trusted publishers* in the gem's sidebar
95
+ instead):
96
+
97
+ | Field | Value |
98
+ |---|---|
99
+ | RubyGem name | `crystil` |
100
+ | Repository owner | `CrystilAI` |
101
+ | Repository name | `ruby-sdk` |
102
+ | Workflow filename | `publish-to-rubygems.yaml` |
103
+ | Environment | `rubygems` |
104
+
105
+ Then create a matching GitHub environment named `rubygems` under *Settings →
106
+ Environments* — optionally with required reviewers, which turns every release
107
+ into an approval gate.
108
+
109
+ All five values are part of the trusted identity. Renaming the workflow file or
110
+ the environment breaks publishing until RubyGems is updated to match. After the
111
+ first successful push, the pending publisher converts to a normal one and the
112
+ configuring user becomes a gem owner.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Crystil
8
+ module API
9
+ # Base HTTP client for Crystil API
10
+ class Base
11
+ def initialize(api_url, api_key, timeout)
12
+ @original_api_url = api_url
13
+ @api_url = "#{api_url}/v1/-"
14
+ @api_key = api_key
15
+ @timeout = timeout
16
+ end
17
+
18
+ protected
19
+
20
+ def get(path)
21
+ request(:get, path)
22
+ end
23
+
24
+ def post(path, body = nil)
25
+ request(:post, path, body)
26
+ end
27
+
28
+ def put(path, body = nil)
29
+ request(:put, path, body)
30
+ end
31
+
32
+ def delete(path)
33
+ request(:delete, path)
34
+ end
35
+
36
+ private
37
+
38
+ def request(method, path, body = nil)
39
+ # Construct full URL - path should start with /
40
+ full_url = "#{@api_url}#{path}"
41
+ uri = URI.parse(full_url)
42
+ http = Net::HTTP.new(uri.host, uri.port)
43
+ http.use_ssl = uri.scheme == "https"
44
+
45
+ # Configure SSL to use system certificates
46
+ if http.use_ssl?
47
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
48
+ http.cert_store = OpenSSL::X509::Store.new
49
+ http.cert_store.set_default_paths
50
+ end
51
+
52
+ http.open_timeout = @timeout
53
+ http.read_timeout = @timeout
54
+
55
+ request = build_request(method, uri, body)
56
+ response = http.request(request)
57
+
58
+ handle_response(response)
59
+ end
60
+
61
+ def build_request(method, uri, body)
62
+ request_class = case method
63
+ when :get then Net::HTTP::Get
64
+ when :post then Net::HTTP::Post
65
+ when :put then Net::HTTP::Put
66
+ when :delete then Net::HTTP::Delete
67
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
68
+ end
69
+
70
+ request = request_class.new(uri.path.empty? ? "/" : uri.path)
71
+ request["Authorization"] = "Bearer #{@api_key}"
72
+ request["Content-Type"] = "application/json"
73
+ request["User-Agent"] = "crystil-ruby/#{Crystil::VERSION}"
74
+
75
+ request.body = JSON.generate(body) if body && %i[post put].include?(method)
76
+
77
+ request
78
+ end
79
+
80
+ def handle_response(response)
81
+ case response
82
+ when Net::HTTPSuccess
83
+ parse_json_response(response.body)
84
+ when Net::HTTPNoContent
85
+ {}
86
+ else
87
+ raise APIError.new(
88
+ "API request failed: #{response.code} #{response.message}",
89
+ status_code: response.code.to_i,
90
+ response_body: response.body
91
+ )
92
+ end
93
+ end
94
+
95
+ def parse_json_response(body)
96
+ return {} if body.nil? || body.empty?
97
+
98
+ JSON.parse(body)
99
+ rescue JSON::ParserError => e
100
+ raise APIError, "Failed to parse JSON response: #{e.message}"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Crystil
6
+ module API
7
+ # API client for workflow invocation operations
8
+ class Invocation < Base
9
+ def initialize(api_url, api_key, timeout)
10
+ super
11
+ @attribution = nil
12
+ end
13
+
14
+ # Set attribution filter for summary queries
15
+ # Returns self for method chaining
16
+ def attribution(parent_id:, parent_name: nil, subsidiary_id: nil, subsidiary_name: nil)
17
+ @attribution = {
18
+ parent: { id: parent_id }
19
+ }
20
+ @attribution[:parent][:name] = parent_name if parent_name
21
+
22
+ if subsidiary_id
23
+ @attribution[:subsidiary] = { id: subsidiary_id }
24
+ @attribution[:subsidiary][:name] = subsidiary_name if subsidiary_name
25
+ end
26
+
27
+ self
28
+ end
29
+
30
+ # Get invocation summary for a workflow
31
+ def summary(workflow_uuid, date_start:, date_end: nil)
32
+ body = {
33
+ date: {
34
+ start: format_date(date_start)
35
+ }
36
+ }
37
+
38
+ body[:date][:end] = format_date(date_end) if date_end
39
+
40
+ body[:attribution] = @attribution if @attribution
41
+
42
+ post("/workflow/#{workflow_uuid}/invocation/summary", body)
43
+ end
44
+
45
+ private
46
+
47
+ def format_date(date)
48
+ return date if date.is_a?(String)
49
+
50
+ # Convert to midnight (beginning of day) in ISO8601 format
51
+ if date.respond_to?(:to_date)
52
+ date.to_date.to_time.iso8601
53
+ else
54
+ date.iso8601
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystil
4
+ module API
5
+ class Sentinel < Base
6
+ def relevance_intercept(payload)
7
+ post("/sentinel/relevance/intercept", payload)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystil
4
+ module API
5
+ # API client for managing a specific workflow
6
+ class Workflow < Base
7
+ def initialize(uuid, api_url, api_key, timeout)
8
+ super(api_url, api_key, timeout)
9
+ @uuid = uuid
10
+ end
11
+
12
+ # Get workflow details
13
+ def details
14
+ get("/workflow/#{@uuid}")
15
+ end
16
+
17
+ # Update workflow label
18
+ def update(label:)
19
+ body = { label: label }
20
+ put("/workflow/#{@uuid}", body)
21
+ end
22
+
23
+ # Delete workflow
24
+ def destroy
25
+ delete("/workflow/#{@uuid}")
26
+ end
27
+
28
+ # Get invocation client for this workflow
29
+ def invocation
30
+ Invocation.new(@uuid, @original_api_url, @api_key, @timeout)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystil
4
+ module API
5
+ # API client for managing workflows
6
+ class Workflows < Base
7
+ # List all workflows
8
+ def list
9
+ get("/workflows")
10
+ end
11
+
12
+ # Create a new workflow
13
+ def create(name:, description: nil)
14
+ post("/workflows", { name: name, description: description }.compact)
15
+ end
16
+
17
+ # Get workflow details
18
+ def details(uuid)
19
+ get("/workflow/#{uuid}")
20
+ end
21
+
22
+ # Update workflow label
23
+ def update(uuid, label:)
24
+ body = { label: label }
25
+ put("/workflow/#{uuid}", body)
26
+ end
27
+
28
+ # Delete workflow
29
+ def destroy(uuid)
30
+ delete("/workflow/#{uuid}")
31
+ end
32
+
33
+ # Get invocation manager for workflow operations
34
+ def invocation
35
+ @invocation ||= Invocation.new(@original_api_url, @api_key, @timeout)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystil
4
+ # Attribution tracks cost hierarchy for API calls
5
+ class Attribution
6
+ attr_reader :parent_id, :parent_name, :subsidiary_id, :subsidiary_name
7
+
8
+ def initialize(parent_id:, parent_name: nil, subsidiary_id: nil, subsidiary_name: nil)
9
+ @parent_id = validate_parent_id!(parent_id)
10
+ @parent_name = validate_string_length!(parent_name, "parent_name") if parent_name
11
+ @subsidiary_id = validate_string_length!(subsidiary_id, "subsidiary_id") if subsidiary_id
12
+ @subsidiary_name = validate_string_length!(subsidiary_name, "subsidiary_name") if subsidiary_name
13
+
14
+ validate_subsidiary_requirements!
15
+ end
16
+
17
+ def to_h
18
+ result = {
19
+ parent: {
20
+ id: parent_id
21
+ }
22
+ }
23
+
24
+ result[:parent][:name] = parent_name if parent_name
25
+
26
+ if subsidiary_id
27
+ result[:subsidiary] = { id: subsidiary_id }
28
+ result[:subsidiary][:name] = subsidiary_name if subsidiary_name
29
+ end
30
+
31
+ result
32
+ end
33
+
34
+ private
35
+
36
+ def validate_parent_id!(value)
37
+ raise ValidationError, "parent_id is required" if value.nil? || value.to_s.empty?
38
+
39
+ value_str = value.to_s
40
+ if value_str.length > 100
41
+ raise ValidationError,
42
+ "parent_id cannot exceed 100 characters (got #{value_str.length})"
43
+ end
44
+
45
+ value_str
46
+ end
47
+
48
+ def validate_string_length!(value, field_name)
49
+ return nil if value.nil?
50
+
51
+ value_str = value.to_s
52
+ if value_str.length > 100
53
+ raise ValidationError,
54
+ "#{field_name} cannot exceed 100 characters (got #{value_str.length})"
55
+ end
56
+
57
+ value_str
58
+ end
59
+
60
+ def validate_subsidiary_requirements!
61
+ return unless subsidiary_name && !subsidiary_id
62
+
63
+ raise ValidationError, "subsidiary_id is required when subsidiary_name is provided"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Crystil
6
+ # Main Crystil client for tracking AI costs
7
+ class Client
8
+ attr_reader :config, :collector, :sentinel
9
+
10
+ def initialize(api_key: nil, collector_url: nil, api_url: nil, timeout: nil)
11
+ api_key ||= ENV.fetch("CRYSTIL_API_KEY", nil)
12
+ raise MissingAPIKeyError if api_key.nil? || api_key.empty?
13
+
14
+ @config = Config.new(
15
+ api_key: api_key,
16
+ collector_url: collector_url,
17
+ api_url: api_url,
18
+ timeout: timeout
19
+ )
20
+ @collector = Collector.new(@config)
21
+ @sentinel = Sentinel.new(@config)
22
+ end
23
+
24
+ # OpenAI provider wrapper
25
+ def openai
26
+ @openai ||= Wrappers::OpenAI.new(@config, @collector, @sentinel)
27
+ end
28
+
29
+ # Anthropic provider wrapper
30
+ def anthropic
31
+ @anthropic ||= Wrappers::Anthropic.new(@config, @collector, @sentinel)
32
+ end
33
+
34
+ # Google GenAI provider wrapper (Tied to google-genai Gem)
35
+ # This is a port of the official Python library, but it does not
36
+ # support system prompts which is used for sentinel. This still
37
+ # works for non-sentinel use cases.
38
+ def google
39
+ @google ||= Wrappers::Google.new(@config, @collector, @sentinel)
40
+ end
41
+
42
+ # Google GenAI provider wrapper (Tied to gemini-ai Gem version 4.3.0)
43
+ # Google doesn't have an official Ruby library, so this is seems to be
44
+ # the most popular unofficial one.
45
+ def geminiai
46
+ @geminiai ||= Wrappers::GeminiAI.new(@config, @collector, @sentinel)
47
+ end
48
+
49
+ # RubyLLM provider wrapper (ruby_llm gem)
50
+ # Supports any provider available via RubyLLM (Anthropic, OpenAI, Google, etc.)
51
+ def ruby_llm
52
+ @ruby_llm ||= Wrappers::RubyLLM.new(@config, @collector, @sentinel)
53
+ end
54
+
55
+ # Groq provider wrapper (groq gem, drnic/groq-ruby)
56
+ # Groq hosts third-party models (Meta Llama, OpenAI gpt-oss, Qwen, etc.).
57
+ # Telemetry sets provider="groq" and title from the model-ID prefix.
58
+ def groq
59
+ @groq ||= Wrappers::Groq.new(@config, @collector, @sentinel)
60
+ end
61
+
62
+ # Set attribution for cost tracking
63
+ def attribution(parent_id:, parent_name: nil, subsidiary_id: nil, subsidiary_name: nil)
64
+ attr = Attribution.new(
65
+ parent_id: parent_id,
66
+ parent_name: parent_name,
67
+ subsidiary_id: subsidiary_id,
68
+ subsidiary_name: subsidiary_name
69
+ )
70
+
71
+ @config.attribution = attr
72
+ self
73
+ end
74
+
75
+ # Start a new transaction
76
+ def new_transaction
77
+ @config.new_transaction
78
+ self
79
+ end
80
+
81
+ # Get workflows API client
82
+ def workflows
83
+ @workflows ||= API::Workflows.new(
84
+ @config.api_url,
85
+ @config.api_key,
86
+ @config.timeout
87
+ )
88
+ end
89
+
90
+ # Gracefully shutdown the client
91
+ def shutdown
92
+ @collector.shutdown
93
+ end
94
+ end
95
+ end