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 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
+ [![Gem](https://img.shields.io/gem/v/marginfuse)](https://rubygems.org/gems/marginfuse)
4
+ [![ci](https://github.com/marginfuse/marginfuse-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/marginfuse/marginfuse-ruby/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue)](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: []