marginfuse 0.1.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 +42 -0
- data/LICENSE +21 -0
- data/README.md +189 -0
- data/lib/marginfuse/client.rb +300 -0
- data/lib/marginfuse/open_router.rb +92 -0
- data/lib/marginfuse/types.rb +47 -0
- data/lib/marginfuse/version.rb +16 -0
- data/lib/marginfuse.rb +18 -0
- metadata +59 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6594a128c2a5670e8eef2c8da7e21a96c305c3e13f66d4262889169c95df5a8a
|
|
4
|
+
data.tar.gz: 96e931bd8cf686b799f306483043c965f81f683eba6d377ce3d5d4bd18d2297e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 139be111ee7a9d54f9c93fd4b2a00712c27c7c2a8ddf0407a7806cd05a43c230dd1714be49654b053cdf3b4db756503f1516ccb42a9f54b859b63acf6924b9c2
|
|
7
|
+
data.tar.gz: 45f3c6d52858f692f327c7be010b7a082d7ee457f027c028c7d1cec7d8128eea825600d3d40b0e1fe0f5ef3065a8c9bdea2e648f2fc8f7f7ce4730e9fb0700c4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
|
5
|
+
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0]
|
|
8
|
+
|
|
9
|
+
First release. Ruby 3.2+, zero dependencies, standard library only.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `MarginFuse::Client#track` reports an AI call that already happened. Returns
|
|
14
|
+
immediately, sends on a background thread with retries, and never raises into
|
|
15
|
+
application code.
|
|
16
|
+
- `MarginFuse::Client#decide` asks whether the next call should run. Fails open
|
|
17
|
+
to `:allow` with `degraded?` true on any timeout or error.
|
|
18
|
+
- `MarginFuse::Client#guard` does the whole loop: ask, yield the decision, report
|
|
19
|
+
the real cost, acknowledge what the application did.
|
|
20
|
+
- `MarginFuse::Client#flush`, for jobs and scripts that would otherwise exit
|
|
21
|
+
before their last events are sent.
|
|
22
|
+
- `MarginFuse::OpenRouter.from` maps an OpenRouter usage object, including the
|
|
23
|
+
gateway's own cost, so gateway figures are exact rather than estimated.
|
|
24
|
+
- `MarginFuse::CONTRACT_VERSION` reports the shared contract this build was
|
|
25
|
+
verified against.
|
|
26
|
+
|
|
27
|
+
### Notes on the design
|
|
28
|
+
|
|
29
|
+
- **No bigdecimal.** The obvious way to format a nano-precision decimal is
|
|
30
|
+
BigDecimal, and it stopped being a default gem in Ruby 3.4, so requiring it
|
|
31
|
+
would quietly turn this into a package with a runtime dependency on a third of
|
|
32
|
+
supported Rubies. `Kernel#format` is core and does the same job.
|
|
33
|
+
- **`guard` yields rather than returning a decision to act on.** Forgetting the
|
|
34
|
+
check once would let a blocked request reach the provider.
|
|
35
|
+
- **`decide` never raises.** A failed decision is an allow with `degraded?` set.
|
|
36
|
+
- **Ruby 3.2, not 3.1.** The development toolchain does not install on 3.1, so
|
|
37
|
+
the claim could not be verified, and 3.1 reached end of life in March 2025.
|
|
38
|
+
Claiming a version CI cannot exercise is how an SDK ends up broken on it.
|
|
39
|
+
- Verified against
|
|
40
|
+
[marginfuse/sdk-contract](https://github.com/marginfuse/sdk-contract): 16
|
|
41
|
+
behavioral scenarios and 13 gateway vectors, the same ones the Node, Python,
|
|
42
|
+
Go, Java and .NET SDKs pass.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pemira Labs
|
|
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,189 @@
|
|
|
1
|
+
# marginfuse
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/marginfuse)
|
|
4
|
+
[](https://github.com/marginfuse/marginfuse-ruby/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Server-side SDK for [MarginFuse](https://marginfuse.com): profitability
|
|
8
|
+
guardrails for AI SaaS. Connect revenue to per-request AI cost, see gross margin
|
|
9
|
+
per customer, and stop loss-making requests before they run.
|
|
10
|
+
|
|
11
|
+
- **Metadata only, by construction.** The event shape has no field for prompts
|
|
12
|
+
or responses, so they cannot be sent. Not a policy, an absence.
|
|
13
|
+
- **Never breaks your app.** It does not raise into your code, and it does not
|
|
14
|
+
block your request on MarginFuse being up. If MarginFuse is unreachable, your
|
|
15
|
+
requests proceed unchanged.
|
|
16
|
+
- **Zero dependencies.** Standard library only, Ruby 3.2+.
|
|
17
|
+
|
|
18
|
+
> **Server side only.** This SDK carries a secret API key. Never ship it in a
|
|
19
|
+
> desktop or mobile application, or anything else a user can read.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bundle add marginfuse
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Track an AI call
|
|
28
|
+
|
|
29
|
+
Monitoring. One call after each AI request, metadata only.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "marginfuse"
|
|
33
|
+
|
|
34
|
+
mf = MarginFuse.new(api_key: ENV.fetch("MARGINFUSE_KEY"))
|
|
35
|
+
|
|
36
|
+
response = client.chat(model: "gpt-4.1", messages: messages)
|
|
37
|
+
|
|
38
|
+
mf.track(
|
|
39
|
+
customer_id: "cus_8x2m91", # your Stripe customer id, or your own
|
|
40
|
+
feature: "ai_chat",
|
|
41
|
+
provider: "openai",
|
|
42
|
+
model: "gpt-4.1",
|
|
43
|
+
usage: {
|
|
44
|
+
input_tokens: response.usage.prompt_tokens,
|
|
45
|
+
output_tokens: response.usage.completion_tokens
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`track` returns immediately and sends on a background thread with retries. In a
|
|
51
|
+
rake task, a Sidekiq job or a script, call `mf.flush` before the process exits,
|
|
52
|
+
or the last events go with it.
|
|
53
|
+
|
|
54
|
+
## Guard a call
|
|
55
|
+
|
|
56
|
+
Protection. Ask before the call runs, and act on the answer.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
outcome = mf.guard(
|
|
60
|
+
customer_id: "cus_8x2m91",
|
|
61
|
+
feature: "ai_chat",
|
|
62
|
+
provider: "openai",
|
|
63
|
+
model: "gpt-4.1"
|
|
64
|
+
) do |decision|
|
|
65
|
+
# decision.model is the one to call: a downgrade verdict changes it.
|
|
66
|
+
response = client.chat(model: decision.model, messages: messages)
|
|
67
|
+
{
|
|
68
|
+
result: response,
|
|
69
|
+
usage: {
|
|
70
|
+
input_tokens: response.usage.prompt_tokens,
|
|
71
|
+
output_tokens: response.usage.completion_tokens
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
case outcome.kind
|
|
77
|
+
when :completed then use(outcome.result)
|
|
78
|
+
when :topup_required then show_topup(outcome.decision.topup_context)
|
|
79
|
+
when :blocked then show_limit_reached()
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
One call does the whole loop: ask, run with the resolved model, report the real
|
|
84
|
+
cost, acknowledge what your application did.
|
|
85
|
+
|
|
86
|
+
### Why a block rather than a returned decision
|
|
87
|
+
|
|
88
|
+
Enforcement must not depend on you remembering to check anything. If `guard`
|
|
89
|
+
returned a decision for you to act on, forgetting the check once would mean a
|
|
90
|
+
blocked request reaches the provider anyway. With a block that is structurally
|
|
91
|
+
impossible: when the verdict is `:block`, the block is never yielded to.
|
|
92
|
+
|
|
93
|
+
### Why decide never raises
|
|
94
|
+
|
|
95
|
+
There is no failure a caller should branch on. A decision that times out or
|
|
96
|
+
errors is an *allow* with `degraded?` true, because MarginFuse being unreachable
|
|
97
|
+
must never become your outage. Transport failures go to `on_error`.
|
|
98
|
+
|
|
99
|
+
## OpenRouter and other gateways
|
|
100
|
+
|
|
101
|
+
Gateways report the real cost of every call. Forward it and your figures are
|
|
102
|
+
exact instead of estimated.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
response = client.chat(model: "anthropic/claude-sonnet-4.5", messages: messages)
|
|
106
|
+
|
|
107
|
+
mf.track(
|
|
108
|
+
customer_id: "cus_8x2m91",
|
|
109
|
+
feature: "ai_chat",
|
|
110
|
+
provider: "openrouter",
|
|
111
|
+
model: "anthropic/claude-sonnet-4.5",
|
|
112
|
+
**MarginFuse::OpenRouter.from(response["usage"])
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Use the helper rather than mapping the fields yourself. OpenRouter's
|
|
117
|
+
`prompt_tokens` already includes cached reads and cache writes, which MarginFuse
|
|
118
|
+
prices separately, so passing it through directly charges every cached token
|
|
119
|
+
twice at the full input rate. The helper also formats the cost as a decimal
|
|
120
|
+
string, because `1.2e-07.to_s` produces exponent notation and the API rejects
|
|
121
|
+
that.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
MarginFuse.new(
|
|
127
|
+
api_key: ENV.fetch("MARGINFUSE_KEY"),
|
|
128
|
+
base_url: "https://api.marginfuse.com", # your own deployment in dev
|
|
129
|
+
timeout: 1.5, # decide budget before failing open
|
|
130
|
+
on_error: ->(error, context) { Rails.logger.warn("marginfuse #{context}: #{error}") }
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`on_error` is the only place transport failures surface. The SDK swallows them
|
|
135
|
+
so they cannot become your outage; without the hook they are silent.
|
|
136
|
+
|
|
137
|
+
### In Rails
|
|
138
|
+
|
|
139
|
+
The client is safe to share, so build one at boot:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
# config/initializers/marginfuse.rb
|
|
143
|
+
MARGINFUSE = MarginFuse.new(
|
|
144
|
+
api_key: Rails.application.credentials.marginfuse_key,
|
|
145
|
+
on_error: ->(error, context) { Rails.logger.warn("marginfuse #{context}: #{error}") }
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## What it sends
|
|
150
|
+
|
|
151
|
+
Everything, and nothing else:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
event_id customer_id feature provider model requested_model
|
|
155
|
+
usage(input_tokens, output_tokens, cached_input_tokens,
|
|
156
|
+
cache_creation_tokens, images, audio_seconds)
|
|
157
|
+
cost_usd occurred_at outcome decision_id retry_of_event_id corrects_event_id
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
There is no field for message content anywhere in the wire types. The
|
|
161
|
+
[conformance suite](https://github.com/marginfuse/sdk-contract) checks this
|
|
162
|
+
against the bytes that actually leave the process, on every scenario.
|
|
163
|
+
|
|
164
|
+
## Conformance
|
|
165
|
+
|
|
166
|
+
This SDK is verified against
|
|
167
|
+
[marginfuse/sdk-contract](https://github.com/marginfuse/sdk-contract), the same
|
|
168
|
+
contract every MarginFuse SDK in every language is held to. It is a submodule
|
|
169
|
+
here, so the pinned commit records exactly which contract a release passed, and
|
|
170
|
+
`MarginFuse::CONTRACT_VERSION` reports it at runtime.
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
git clone --recurse-submodules https://github.com/marginfuse/marginfuse-ruby
|
|
174
|
+
cd marginfuse-ruby
|
|
175
|
+
bundle install
|
|
176
|
+
bundle exec rake test # unit tests, plus the shared gateway vectors
|
|
177
|
+
npm --prefix contract/harness install
|
|
178
|
+
npm --prefix contract/harness run conformance ruby
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Links
|
|
182
|
+
|
|
183
|
+
- [MarginFuse](https://marginfuse.com), product and pricing
|
|
184
|
+
- [Documentation](https://marginfuse.com/docs)
|
|
185
|
+
- [API reference](https://api.marginfuse.com/openapi.json)
|
|
186
|
+
- [Security policy](SECURITY.md)
|
|
187
|
+
- [Contributing](CONTRIBUTING.md)
|
|
188
|
+
|
|
189
|
+
MIT, Pemira Labs.
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
# Time#iso8601 lives here. Some Rubies load it transitively and some do not, so
|
|
7
|
+
# a library that assumes it is present throws into the caller on the ones that
|
|
8
|
+
# do not, which is the one thing this SDK promises never to do.
|
|
9
|
+
require "time"
|
|
10
|
+
require "uri"
|
|
11
|
+
|
|
12
|
+
module MarginFuse
|
|
13
|
+
# Server-side SDK for MarginFuse: profitability guardrails for AI SaaS.
|
|
14
|
+
#
|
|
15
|
+
# Reliability contract: this SDK never raises into application code and never
|
|
16
|
+
# blocks a request on MarginFuse availability. {#decide} fails open to
|
|
17
|
+
# +:allow+ on any timeout or error; {#track} and {#acknowledge} retry on a
|
|
18
|
+
# background thread and surface problems only through +on_error+.
|
|
19
|
+
#
|
|
20
|
+
# Zero dependencies, standard library only. Server side only: it carries a
|
|
21
|
+
# secret API key.
|
|
22
|
+
#
|
|
23
|
+
# mf = MarginFuse::Client.new(api_key: ENV.fetch("MARGINFUSE_KEY"))
|
|
24
|
+
# mf.track(customer_id: "cus_8x2m91", provider: "openai", model: "gpt-4.1",
|
|
25
|
+
# usage: { input_tokens: 1204, output_tokens: 388 })
|
|
26
|
+
class Client
|
|
27
|
+
DEFAULT_BASE_URL = "https://api.marginfuse.com"
|
|
28
|
+
DEFAULT_TIMEOUT = 1.5
|
|
29
|
+
TRACK_RETRIES = 3
|
|
30
|
+
USER_AGENT = "marginfuse-ruby/#{VERSION}".freeze
|
|
31
|
+
|
|
32
|
+
# Usage keys are snake_case here and camelCase on the wire.
|
|
33
|
+
USAGE_KEYS = {
|
|
34
|
+
input_tokens: "inputTokens",
|
|
35
|
+
output_tokens: "outputTokens",
|
|
36
|
+
cached_input_tokens: "cachedInputTokens",
|
|
37
|
+
cache_creation_tokens: "cacheCreationTokens",
|
|
38
|
+
images: "images",
|
|
39
|
+
audio_seconds: "audioSeconds"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# @param api_key [String] your project API key
|
|
43
|
+
# @param base_url [String] point at your own deployment in development
|
|
44
|
+
# @param timeout [Float] seconds {#decide} waits before failing open
|
|
45
|
+
# @param on_error [Proc] receives (error, context) for failures the SDK
|
|
46
|
+
# swallowed. Without it they are silent by design: this SDK is in your
|
|
47
|
+
# request path and must not become your outage.
|
|
48
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, on_error: nil)
|
|
49
|
+
raise ArgumentError, "MarginFuse: api_key is required" if api_key.nil? || api_key.empty?
|
|
50
|
+
|
|
51
|
+
@api_key = api_key
|
|
52
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
53
|
+
@timeout = timeout
|
|
54
|
+
@on_error = on_error
|
|
55
|
+
@pending = []
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Asks whether the next call should run. Always returns a decision.
|
|
60
|
+
#
|
|
61
|
+
# On any timeout or error this returns +action: :allow+ with
|
|
62
|
+
# +degraded: true+: MarginFuse being unreachable must never become your
|
|
63
|
+
# outage.
|
|
64
|
+
#
|
|
65
|
+
# @return [Decision]
|
|
66
|
+
def decide(customer_id:, provider:, model:, feature: nil, expected_usage: nil)
|
|
67
|
+
body = {
|
|
68
|
+
"customerId" => customer_id,
|
|
69
|
+
"feature" => feature,
|
|
70
|
+
"provider" => provider,
|
|
71
|
+
"model" => model,
|
|
72
|
+
"expectedUsage" => usage_payload(expected_usage)
|
|
73
|
+
}.compact
|
|
74
|
+
body.delete("expectedUsage") if body["expectedUsage"] && body["expectedUsage"].empty?
|
|
75
|
+
|
|
76
|
+
response = post("/v1/decisions", body, @timeout)
|
|
77
|
+
unless (200..299).cover?(response.code.to_i)
|
|
78
|
+
report(RuntimeError.new("decide: HTTP #{response.code}"), "decide")
|
|
79
|
+
return fail_open(provider, model, "server responded #{response.code}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
parsed = JSON.parse(response.body)
|
|
83
|
+
Decision.new(
|
|
84
|
+
id: parsed["id"],
|
|
85
|
+
action: Decision.action_from_wire(parsed["action"]),
|
|
86
|
+
model: parsed["model"] || model,
|
|
87
|
+
provider: parsed["provider"] || provider,
|
|
88
|
+
topup_context: parsed["topupContext"],
|
|
89
|
+
degraded: parsed["degraded"] || false,
|
|
90
|
+
degraded_reason: parsed["degradedReason"]
|
|
91
|
+
)
|
|
92
|
+
# Net::OpenTimeout, Net::ReadTimeout and Net::WriteTimeout are all
|
|
93
|
+
# Timeout::Error, so this catches every way the request can time out,
|
|
94
|
+
# including the one an explicit list would have missed.
|
|
95
|
+
rescue Timeout::Error => e
|
|
96
|
+
report(e, "decide")
|
|
97
|
+
fail_open(provider, model, "timeout")
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
report(e, "decide")
|
|
100
|
+
fail_open(provider, model, "unreachable")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Reports a call that already happened. Returns immediately and sends on a
|
|
104
|
+
# background thread with retries.
|
|
105
|
+
#
|
|
106
|
+
# Call {#flush} before the process exits, or the last events go with it.
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
def track(customer_id:, provider:, model:, usage: nil, feature: nil, requested_model: nil,
|
|
110
|
+
cost_usd: nil, event_id: nil, occurred_at: nil, outcome: :success,
|
|
111
|
+
decision_id: nil, retry_of_event_id: nil, corrects_event_id: nil)
|
|
112
|
+
event = {
|
|
113
|
+
"eventId" => event_id || "evt_#{SecureRandom.uuid}",
|
|
114
|
+
"customerId" => customer_id,
|
|
115
|
+
"feature" => feature,
|
|
116
|
+
"provider" => provider,
|
|
117
|
+
"model" => model,
|
|
118
|
+
"requestedModel" => requested_model,
|
|
119
|
+
"usage" => usage_payload(usage) || {},
|
|
120
|
+
"costUsd" => cost_usd,
|
|
121
|
+
"occurredAt" => (occurred_at || Time.now).utc.iso8601(6),
|
|
122
|
+
"outcome" => outcome.to_s,
|
|
123
|
+
"decisionId" => decision_id,
|
|
124
|
+
"retryOfEventId" => retry_of_event_id,
|
|
125
|
+
"correctsEventId" => corrects_event_id
|
|
126
|
+
}.compact
|
|
127
|
+
|
|
128
|
+
background { send_event(event) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# {#track} for jobs and scripts that must not exit early.
|
|
132
|
+
# @return [void]
|
|
133
|
+
def track_and_wait(**params)
|
|
134
|
+
track(**params)
|
|
135
|
+
flush
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Tells MarginFuse what your application did with a decision.
|
|
139
|
+
# @return [void]
|
|
140
|
+
def acknowledge(decision_id, acknowledgment)
|
|
141
|
+
background do
|
|
142
|
+
response = post("/v1/decisions/#{URI.encode_www_form_component(decision_id)}/ack",
|
|
143
|
+
{ "acknowledgment" => acknowledgment.to_s }, 5.0)
|
|
144
|
+
unless (200..299).cover?(response.code.to_i)
|
|
145
|
+
report(RuntimeError.new("ack: HTTP #{response.code}"), "acknowledge")
|
|
146
|
+
end
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
report(e, "acknowledge")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Runs the whole loop: ask, run, report, acknowledge.
|
|
153
|
+
#
|
|
154
|
+
# Yields the decision to the block, which must return a hash with +:usage+
|
|
155
|
+
# and optionally +:result+, +:cost_usd+ and +:outcome+. Use
|
|
156
|
+
# +decision.model+: a downgrade verdict changes it.
|
|
157
|
+
#
|
|
158
|
+
# It yields rather than returning a decision for you to act on, because
|
|
159
|
+
# enforcement must not depend on the caller remembering to check anything.
|
|
160
|
+
# When the verdict is block, the block is never yielded to.
|
|
161
|
+
#
|
|
162
|
+
# An exception from your block propagates unchanged: your error handling
|
|
163
|
+
# owns provider failures. The attempt is recorded first, because the
|
|
164
|
+
# provider may still have charged for it.
|
|
165
|
+
#
|
|
166
|
+
# @return [GuardOutcome]
|
|
167
|
+
def guard(customer_id:, provider:, model:, feature: nil, expected_usage: nil)
|
|
168
|
+
decision = decide(customer_id: customer_id, provider: provider, model: model,
|
|
169
|
+
feature: feature, expected_usage: expected_usage)
|
|
170
|
+
|
|
171
|
+
# Enforcement depends on the ACTION alone. A missing id costs an
|
|
172
|
+
# acknowledgment; it must never turn a block into a provider call.
|
|
173
|
+
if decision.action == :block
|
|
174
|
+
acknowledge(decision.id, :blocked_before_provider_call) if decision.id
|
|
175
|
+
return GuardOutcome.new(kind: :blocked, decision: decision)
|
|
176
|
+
end
|
|
177
|
+
if decision.action == :topup_required
|
|
178
|
+
acknowledge(decision.id, :presented_topup) if decision.id
|
|
179
|
+
return GuardOutcome.new(kind: :topup_required, decision: decision)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
model_used = decision.action == :downgrade ? decision.model : model
|
|
183
|
+
|
|
184
|
+
begin
|
|
185
|
+
call = yield(decision)
|
|
186
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
187
|
+
track(customer_id: customer_id, feature: feature, provider: provider,
|
|
188
|
+
model: model_used, requested_model: model, usage: {},
|
|
189
|
+
outcome: :provider_error, decision_id: decision.id)
|
|
190
|
+
acknowledge(decision.id, :proceeded_as_requested) if decision.id
|
|
191
|
+
raise e
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
call ||= {}
|
|
195
|
+
track(customer_id: customer_id, feature: feature, provider: provider,
|
|
196
|
+
model: model_used, requested_model: model, usage: call[:usage],
|
|
197
|
+
cost_usd: call[:cost_usd], outcome: call[:outcome] || :success,
|
|
198
|
+
decision_id: decision.id)
|
|
199
|
+
if decision.id
|
|
200
|
+
acknowledge(decision.id,
|
|
201
|
+
decision.action == :downgrade ? :used_downgrade_model : :proceeded_as_requested)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
GuardOutcome.new(kind: :completed, decision: decision, result: call[:result])
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Waits for queued events and acknowledgments. Never raises.
|
|
208
|
+
# @return [void]
|
|
209
|
+
def flush
|
|
210
|
+
threads = @mutex.synchronize { @pending.dup }
|
|
211
|
+
threads.each do |thread|
|
|
212
|
+
thread.join
|
|
213
|
+
rescue StandardError
|
|
214
|
+
# already surfaced through on_error
|
|
215
|
+
end
|
|
216
|
+
@mutex.synchronize { @pending.compact! }
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
def fail_open(provider, model, reason)
|
|
223
|
+
Decision.new(id: nil, action: :allow, model: model, provider: provider,
|
|
224
|
+
topup_context: nil, degraded: true, degraded_reason: reason)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def usage_payload(usage)
|
|
228
|
+
return nil if usage.nil?
|
|
229
|
+
|
|
230
|
+
USAGE_KEYS.each_with_object({}) do |(key, wire), out|
|
|
231
|
+
value = usage.is_a?(Hash) ? (usage[key] || usage[key.to_s]) : nil
|
|
232
|
+
out[wire] = value unless value.nil?
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def report(error, context)
|
|
237
|
+
return if @on_error.nil?
|
|
238
|
+
|
|
239
|
+
@on_error.call(error, context)
|
|
240
|
+
rescue StandardError
|
|
241
|
+
# a broken hook is not our failure mode
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def background(&)
|
|
245
|
+
thread = Thread.new(&)
|
|
246
|
+
thread.report_on_exception = false
|
|
247
|
+
@mutex.synchronize do
|
|
248
|
+
@pending.select!(&:alive?)
|
|
249
|
+
@pending << thread
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def send_event(event)
|
|
254
|
+
last = nil
|
|
255
|
+
attempt = 0
|
|
256
|
+
while attempt < TRACK_RETRIES
|
|
257
|
+
outcome, last = attempt_send(event)
|
|
258
|
+
return if outcome == :done
|
|
259
|
+
|
|
260
|
+
sleep(0.25 * (2**attempt))
|
|
261
|
+
attempt += 1
|
|
262
|
+
end
|
|
263
|
+
report(last, "track") if last
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Returns [:done, nil] when there is nothing left to try, either because the
|
|
267
|
+
# event landed or because retrying cannot help.
|
|
268
|
+
def attempt_send(event)
|
|
269
|
+
response = post("/v1/events", { "events" => [event] }, 5.0)
|
|
270
|
+
status = response.code.to_i
|
|
271
|
+
return [:done, nil] if (200..299).cover?(status)
|
|
272
|
+
|
|
273
|
+
if (400..499).cover?(status) && status != 429
|
|
274
|
+
# A malformed event is malformed on every attempt.
|
|
275
|
+
report(RuntimeError.new("track: HTTP #{status} #{response.body.to_s[0, 200]}"), "track")
|
|
276
|
+
return [:done, nil]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
[:retry, RuntimeError.new("track: HTTP #{status}")]
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
[:retry, e]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def post(path, body, timeout)
|
|
285
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
286
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
287
|
+
http.use_ssl = uri.scheme == "https"
|
|
288
|
+
http.open_timeout = timeout
|
|
289
|
+
http.read_timeout = timeout
|
|
290
|
+
|
|
291
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
292
|
+
request["authorization"] = "Bearer #{@api_key}"
|
|
293
|
+
request["content-type"] = "application/json"
|
|
294
|
+
request["user-agent"] = USER_AGENT
|
|
295
|
+
request.body = JSON.generate(body)
|
|
296
|
+
|
|
297
|
+
http.request(request)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MarginFuse
|
|
4
|
+
# OpenRouter helper.
|
|
5
|
+
#
|
|
6
|
+
# OpenRouter returns a +usage+ object carrying the provider-final +cost+.
|
|
7
|
+
# Forwarding it is what makes an OpenRouter integration exact rather than
|
|
8
|
+
# estimated: MarginFuse cannot know what a gateway charged, because routing,
|
|
9
|
+
# fees and BYOK terms are not visible in a usage event.
|
|
10
|
+
#
|
|
11
|
+
# Two details this helper exists to get right, both of which silently misstate
|
|
12
|
+
# margin when hand-rolled:
|
|
13
|
+
#
|
|
14
|
+
# 1. +prompt_tokens+ is the TOTAL input count. Cached reads and cache writes
|
|
15
|
+
# are already inside it, and MarginFuse prices those as three separate
|
|
16
|
+
# charges and adds them up, so passing the total through charges every
|
|
17
|
+
# cached token twice at the full uncached rate.
|
|
18
|
+
# 2. +cost+ is a Float, and +to_s+ renders small ones in exponent notation
|
|
19
|
+
# ("1.2e-07"), which the API rejects as a decimal string.
|
|
20
|
+
module OpenRouter
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Maps an OpenRouter +usage+ object to MarginFuse keyword arguments.
|
|
24
|
+
#
|
|
25
|
+
# r = client.chat(...)
|
|
26
|
+
# mf.track(customer_id: cid, provider: "openrouter", model: model,
|
|
27
|
+
# **MarginFuse::OpenRouter.from(r["usage"]))
|
|
28
|
+
#
|
|
29
|
+
# +:cost_usd+ is omitted when the response carried no cost, which lets the
|
|
30
|
+
# event fall through to MarginFuse's own pricing instead of claiming a $0
|
|
31
|
+
# charge.
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash]
|
|
34
|
+
def from(usage = nil)
|
|
35
|
+
source = usage.is_a?(Hash) ? usage : {}
|
|
36
|
+
details = source["prompt_tokens_details"] || source[:prompt_tokens_details]
|
|
37
|
+
details = {} unless details.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
cached = to_int(details["cached_tokens"] || details[:cached_tokens])
|
|
40
|
+
cache_writes = to_int(details["cache_write_tokens"] || details[:cache_write_tokens])
|
|
41
|
+
# What is left after the cached parts is what was billed at the full input
|
|
42
|
+
# rate. Clamped at zero so a provider reporting these differently degrades
|
|
43
|
+
# to "no fresh input" rather than a negative charge.
|
|
44
|
+
prompt = to_int(source["prompt_tokens"] || source[:prompt_tokens])
|
|
45
|
+
fresh = [0, prompt - cached - cache_writes].max
|
|
46
|
+
completion = to_int(source["completion_tokens"] || source[:completion_tokens])
|
|
47
|
+
|
|
48
|
+
mapped = {}
|
|
49
|
+
mapped[:input_tokens] = fresh if fresh.positive?
|
|
50
|
+
mapped[:output_tokens] = completion if completion.positive?
|
|
51
|
+
mapped[:cached_input_tokens] = cached if cached.positive?
|
|
52
|
+
mapped[:cache_creation_tokens] = cache_writes if cache_writes.positive?
|
|
53
|
+
|
|
54
|
+
out = { usage: mapped }
|
|
55
|
+
cost = source["cost"] || source[:cost]
|
|
56
|
+
return out unless cost.is_a?(Numeric) && !cost.is_a?(Complex)
|
|
57
|
+
return out if cost.respond_to?(:nan?) && cost.nan?
|
|
58
|
+
return out if cost.respond_to?(:infinite?) && cost.infinite?
|
|
59
|
+
return out if cost.negative?
|
|
60
|
+
|
|
61
|
+
out[:cost_usd] = credits_to_usd(cost)
|
|
62
|
+
out
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_int(value)
|
|
66
|
+
return 0 unless value.is_a?(Numeric) && !value.is_a?(Complex)
|
|
67
|
+
return 0 if value.respond_to?(:nan?) && value.nan?
|
|
68
|
+
return 0 if value.respond_to?(:infinite?) && value.infinite?
|
|
69
|
+
return 0 unless value.positive?
|
|
70
|
+
|
|
71
|
+
value.round
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# OpenRouter credits (1 credit = 1 USD) as a decimal string the API takes.
|
|
75
|
+
#
|
|
76
|
+
# Fixed point to nano precision: +to_s+ emits exponent notation for the
|
|
77
|
+
# small costs cheap models produce, and money below a nano cannot be
|
|
78
|
+
# represented at all, so it rounds down rather than pretending otherwise.
|
|
79
|
+
def credits_to_usd(cost)
|
|
80
|
+
# Formatted to ten decimals and then truncated to nine, rather than
|
|
81
|
+
# rounded: money below a nano cannot be represented, so it rounds down
|
|
82
|
+
# instead of inventing precision it does not have.
|
|
83
|
+
#
|
|
84
|
+
# bigdecimal would read more clearly and stopped being a default gem in
|
|
85
|
+
# Ruby 3.4, so requiring it would quietly turn this into a package with a
|
|
86
|
+
# runtime dependency. Kernel#format is core.
|
|
87
|
+
text = format("%.10f", cost)[0..-2]
|
|
88
|
+
text = text.sub(/\.?0+\z/, "") if text.include?(".")
|
|
89
|
+
text.empty? || text == "-0" ? "0" : text
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MarginFuse
|
|
4
|
+
# The wire values for a verdict. An action a newer server sends and this
|
|
5
|
+
# version cannot enforce resolves to :allow, because an unrecognised value
|
|
6
|
+
# must never silently become a block.
|
|
7
|
+
DECISION_ACTIONS = {
|
|
8
|
+
"allow" => :allow,
|
|
9
|
+
"downgrade" => :downgrade,
|
|
10
|
+
"topup_required" => :topup_required,
|
|
11
|
+
"block" => :block
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# A verdict from MarginFuse.
|
|
15
|
+
#
|
|
16
|
+
# +degraded+ is true when MarginFuse could not reach a verdict and the request
|
|
17
|
+
# was allowed through unprotected. +id+ is nil in that case, which is exactly
|
|
18
|
+
# why enforcement must depend on +action+ alone.
|
|
19
|
+
Decision = Struct.new(
|
|
20
|
+
:id, :action, :model, :provider, :topup_context, :degraded, :degraded_reason,
|
|
21
|
+
keyword_init: true
|
|
22
|
+
) do
|
|
23
|
+
def self.action_from_wire(value)
|
|
24
|
+
DECISION_ACTIONS.fetch(value, :allow)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def degraded?
|
|
28
|
+
!!degraded
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The result of the whole guard loop. +kind+ is :completed, :blocked or
|
|
33
|
+
# :topup_required. +result+ is your block's own return value.
|
|
34
|
+
GuardOutcome = Struct.new(:kind, :decision, :result, keyword_init: true) do
|
|
35
|
+
def completed?
|
|
36
|
+
kind == :completed
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def blocked?
|
|
40
|
+
kind == :blocked
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def topup_required?
|
|
44
|
+
kind == :topup_required
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MarginFuse
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
|
|
6
|
+
# The version of the shared SDK contract this build was verified against.
|
|
7
|
+
#
|
|
8
|
+
# Gem versions differ per language, because each tracks its own breaking
|
|
9
|
+
# changes: a rename in Python must not tell Ruby users something broke. What
|
|
10
|
+
# makes the SDKs interchangeable is this, not the gem version. Two SDKs
|
|
11
|
+
# reporting the same contract version have passed the same scenarios and the
|
|
12
|
+
# same vectors.
|
|
13
|
+
#
|
|
14
|
+
# See github.com/marginfuse/sdk-contract
|
|
15
|
+
CONTRACT_VERSION = 1
|
|
16
|
+
end
|
data/lib/marginfuse.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "marginfuse/version"
|
|
4
|
+
require_relative "marginfuse/types"
|
|
5
|
+
require_relative "marginfuse/client"
|
|
6
|
+
require_relative "marginfuse/open_router"
|
|
7
|
+
|
|
8
|
+
# MarginFuse: profitability guardrails for AI SaaS.
|
|
9
|
+
#
|
|
10
|
+
# Server side only: the SDK carries a secret API key.
|
|
11
|
+
module MarginFuse
|
|
12
|
+
# Convenience for the common case.
|
|
13
|
+
#
|
|
14
|
+
# mf = MarginFuse.new(api_key: ENV.fetch("MARGINFUSE_KEY"))
|
|
15
|
+
def self.new(**)
|
|
16
|
+
Client.new(**)
|
|
17
|
+
end
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: marginfuse
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Pemira Labs
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |-
|
|
14
|
+
MarginFuse server-side SDK. Connect revenue to per-request AI cost, see gross
|
|
15
|
+
margin per customer, and stop loss-making requests before they run. Sends
|
|
16
|
+
usage metadata only, never prompts or responses.
|
|
17
|
+
email:
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/marginfuse.rb
|
|
26
|
+
- lib/marginfuse/client.rb
|
|
27
|
+
- lib/marginfuse/open_router.rb
|
|
28
|
+
- lib/marginfuse/types.rb
|
|
29
|
+
- lib/marginfuse/version.rb
|
|
30
|
+
homepage: https://marginfuse.com
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
homepage_uri: https://marginfuse.com
|
|
35
|
+
documentation_uri: https://marginfuse.com/docs
|
|
36
|
+
source_code_uri: https://github.com/marginfuse/marginfuse-ruby
|
|
37
|
+
bug_tracker_uri: https://github.com/marginfuse/marginfuse-ruby/issues
|
|
38
|
+
changelog_uri: https://github.com/marginfuse/marginfuse-ruby/blob/main/CHANGELOG.md
|
|
39
|
+
rubygems_mfa_required: 'true'
|
|
40
|
+
post_install_message:
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 3.2.0
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 3.5.22
|
|
56
|
+
signing_key:
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: 'AI profitability guardrails: connect revenue to per-request AI cost.'
|
|
59
|
+
test_files: []
|