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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +77 -0
- data/LICENSE +21 -0
- data/README.md +112 -0
- data/lib/crystil/api/base.rb +104 -0
- data/lib/crystil/api/invocation.rb +59 -0
- data/lib/crystil/api/sentinel.rb +11 -0
- data/lib/crystil/api/workflow.rb +34 -0
- data/lib/crystil/api/workflows.rb +39 -0
- data/lib/crystil/attribution.rb +66 -0
- data/lib/crystil/client.rb +95 -0
- data/lib/crystil/collector.rb +88 -0
- data/lib/crystil/config.rb +79 -0
- data/lib/crystil/errors.rb +35 -0
- data/lib/crystil/sentinel.rb +100 -0
- data/lib/crystil/version.rb +5 -0
- data/lib/crystil/wrappers/anthropic.rb +112 -0
- data/lib/crystil/wrappers/base.rb +259 -0
- data/lib/crystil/wrappers/constants.rb +12 -0
- data/lib/crystil/wrappers/geminiai.rb +173 -0
- data/lib/crystil/wrappers/google.rb +133 -0
- data/lib/crystil/wrappers/groq.rb +295 -0
- data/lib/crystil/wrappers/openai.rb +249 -0
- data/lib/crystil/wrappers/ruby_llm.rb +170 -0
- data/lib/crystil.rb +52 -0
- data/sig/crystil/api/base.rbs +24 -0
- data/sig/crystil/api/invocation.rbs +16 -0
- data/sig/crystil/api/sentinel.rbs +9 -0
- data/sig/crystil/api/workflow.rbs +15 -0
- data/sig/crystil/api/workflows.rbs +16 -0
- data/sig/crystil/attribution.rbs +17 -0
- data/sig/crystil/client.rbs +29 -0
- data/sig/crystil/collector.rbs +20 -0
- data/sig/crystil/config.rbs +33 -0
- data/sig/crystil/errors.rbs +28 -0
- data/sig/crystil/sentinel.rbs +17 -0
- data/sig/crystil/version.rbs +5 -0
- data/sig/crystil/wrappers/anthropic.rbs +18 -0
- data/sig/crystil/wrappers/base.rbs +21 -0
- data/sig/crystil/wrappers/constants.rbs +10 -0
- data/sig/crystil/wrappers/geminiai.rbs +19 -0
- data/sig/crystil/wrappers/google.rbs +19 -0
- data/sig/crystil/wrappers/groq.rbs +22 -0
- data/sig/crystil/wrappers/openai.rbs +19 -0
- data/sig/crystil/wrappers/ruby_llm.rbs +21 -0
- 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,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
|