marginfuse 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +31 -0
- data/lib/marginfuse/client.rb +77 -14
- data/lib/marginfuse/types.rb +17 -0
- data/lib/marginfuse/version.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0b1ac060efeb37a1cff83a21bfec10f5e5972c67622a637100b00746a9defbe
|
|
4
|
+
data.tar.gz: '0058b2089e6d46488e0db908ad66e675f83e1b4a8116abdb2edef4a557e7445f'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79059326eec63bb75f28c62b93d144d236d14450d477100be60423b7a6a655dfba9556277f9452ae55f204b8c90e60bb43fcc9008fe3bfedc5b4dbf8f0c334a8
|
|
7
|
+
data.tar.gz: 0f8bea6add4ee66ac6badb04073b362b08f1ab22524479407b9cd1b475b7d2f152e69d020a41c712eadef3dcf5fd667bc82ce005edb8e39b3682bc4f7734ceb9
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,58 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
|
5
5
|
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.0]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- A downgrade that crosses vendors is reported against the vendor that actually
|
|
12
|
+
ran it. `guard()` already ran the model the server chose, but the usage event
|
|
13
|
+
still named the requested provider, so the call was priced from the wrong
|
|
14
|
+
catalog and the saving the downgrade exists to prove was computed against the
|
|
15
|
+
wrong basis. An `allow` is unchanged, because the decision already defaults
|
|
16
|
+
its provider to the requested one.
|
|
17
|
+
- A downgrade whose provider call then fails is acknowledged as
|
|
18
|
+
`used_downgrade_model` rather than `proceeded_as_requested`. The cheaper model
|
|
19
|
+
did run; what failed came after. Reporting otherwise told reconciliation the
|
|
20
|
+
policy never applied, which skewed realized-savings attribution on the error
|
|
21
|
+
path.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Pinned contract v2, whose new scenarios cover both corrections above and add
|
|
26
|
+
a privacy check that hands the SDK content-bearing fields and scans the bytes
|
|
27
|
+
that actually leave the process.
|
|
28
|
+
|
|
29
|
+
## [0.2.0]
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `identify`: tell MarginFuse who a customer is and which plan they are on.
|
|
34
|
+
|
|
35
|
+
MarginFuse can now compute margin without a revenue source connected, from
|
|
36
|
+
plans you declare in Settings and a plan assigned per customer. This call is
|
|
37
|
+
how your application assigns that plan itself.
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
mf.identify(customer_id: "user_8x2m91", plan: "pro", name: "Acme Studio")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`plan` is the key of a plan declared in MarginFuse, not a Stripe price id.
|
|
44
|
+
Safe to call on every sign-in: sending the plan the customer is already on
|
|
45
|
+
changes nothing. `period_start` backdates the cycle, `clear_plan` ends it.
|
|
46
|
+
|
|
47
|
+
Unlike `track`, this one reports failure instead of failing quietly. A wrong
|
|
48
|
+
plan is a wrong margin, and there is no safe default for "I could not record
|
|
49
|
+
what this customer pays". Check `result.ok?`; `on_error` is called too. It
|
|
50
|
+
still never raises into your code.
|
|
51
|
+
|
|
52
|
+
- `plan` on `track`, `guard` and `decide`, so a plan can ride along with usage
|
|
53
|
+
rather than needing its own call. There it is a hint: a key that does not
|
|
54
|
+
resolve is ignored rather than failing your event, because usage must never
|
|
55
|
+
be lost to a plan note.
|
|
56
|
+
|
|
57
|
+
Both are additive. Existing code keeps working unchanged.
|
|
58
|
+
|
|
7
59
|
## [0.1.0]
|
|
8
60
|
|
|
9
61
|
First release. Ruby 3.2+, zero dependencies, standard library only.
|
data/README.md
CHANGED
|
@@ -96,6 +96,37 @@ There is no failure a caller should branch on. A decision that times out or
|
|
|
96
96
|
errors is an *allow* with `degraded?` true, because MarginFuse being unreachable
|
|
97
97
|
must never become your outage. Transport failures go to `on_error`.
|
|
98
98
|
|
|
99
|
+
## Tell MarginFuse what a customer pays
|
|
100
|
+
|
|
101
|
+
Margin needs a revenue side. With Stripe connected it comes from there. Without
|
|
102
|
+
one, you declare your plans in MarginFuse and say which plan each customer is
|
|
103
|
+
on:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
result = mf.identify(
|
|
107
|
+
customer_id: "user_8x2m91",
|
|
108
|
+
plan: "pro", # the key of a plan you declared in Settings
|
|
109
|
+
name: "Acme Studio",
|
|
110
|
+
metadata: { "tier" => "legacy" } # labels segment policies can match on
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
warn "MarginFuse identify: #{result.error}" unless result.ok?
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Safe to call on every sign-in: sending the plan the customer is already on
|
|
117
|
+
changes nothing. Sending a different one ends the current cycle and prorates
|
|
118
|
+
what accrued. `period_start` backdates the cycle for a customer who has been
|
|
119
|
+
paying since an earlier date; `clear_plan: true` takes them off plans.
|
|
120
|
+
|
|
121
|
+
This is the one call that does not fail open. `track` retries later and
|
|
122
|
+
`decide` allows, because both have a safe default; "I could not record what
|
|
123
|
+
this customer pays" has none, and a wrong plan is a wrong margin. So it reports
|
|
124
|
+
the failure to you instead of swallowing it. It still never raises.
|
|
125
|
+
|
|
126
|
+
`track`, `guard` and `decide` also accept a `plan`, so it can ride along with
|
|
127
|
+
usage rather than needing its own call. There it is a hint: a key that does not
|
|
128
|
+
resolve is ignored rather than failing your event.
|
|
129
|
+
|
|
99
130
|
## OpenRouter and other gateways
|
|
100
131
|
|
|
101
132
|
Gateways report the real cost of every call. Forward it and your figures are
|
data/lib/marginfuse/client.rb
CHANGED
|
@@ -63,9 +63,10 @@ module MarginFuse
|
|
|
63
63
|
# outage.
|
|
64
64
|
#
|
|
65
65
|
# @return [Decision]
|
|
66
|
-
def decide(customer_id:, provider:, model:, feature: nil, expected_usage: nil)
|
|
66
|
+
def decide(customer_id:, provider:, model:, plan: nil, feature: nil, expected_usage: nil)
|
|
67
67
|
body = {
|
|
68
68
|
"customerId" => customer_id,
|
|
69
|
+
"plan" => plan,
|
|
69
70
|
"feature" => feature,
|
|
70
71
|
"provider" => provider,
|
|
71
72
|
"model" => model,
|
|
@@ -106,12 +107,14 @@ module MarginFuse
|
|
|
106
107
|
# Call {#flush} before the process exits, or the last events go with it.
|
|
107
108
|
#
|
|
108
109
|
# @return [void]
|
|
109
|
-
def track(customer_id:, provider:, model:, usage: nil,
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
def track(customer_id:, provider:, model:, usage: nil, plan: nil, feature: nil,
|
|
111
|
+
requested_model: nil, cost_usd: nil, event_id: nil, occurred_at: nil,
|
|
112
|
+
outcome: :success, decision_id: nil, retry_of_event_id: nil,
|
|
113
|
+
corrects_event_id: nil)
|
|
112
114
|
event = {
|
|
113
115
|
"eventId" => event_id || "evt_#{SecureRandom.uuid}",
|
|
114
116
|
"customerId" => customer_id,
|
|
117
|
+
"plan" => plan,
|
|
115
118
|
"feature" => feature,
|
|
116
119
|
"provider" => provider,
|
|
117
120
|
"model" => model,
|
|
@@ -128,6 +131,58 @@ module MarginFuse
|
|
|
128
131
|
background { send_event(event) }
|
|
129
132
|
end
|
|
130
133
|
|
|
134
|
+
# Tells MarginFuse who a customer is and what plan they are on.
|
|
135
|
+
#
|
|
136
|
+
# +plan+ is the key of a plan you declared in MarginFuse Settings, not a
|
|
137
|
+
# Stripe price id. MarginFuse derives that customer's revenue from the
|
|
138
|
+
# plan's price for every cycle, which is what makes margin per customer and
|
|
139
|
+
# margin policies work with no revenue source connected. Those figures are
|
|
140
|
+
# labeled as a declared price wherever they appear, because nobody
|
|
141
|
+
# confirmed collection.
|
|
142
|
+
#
|
|
143
|
+
# Safe to call on every sign-in: sending the plan the customer is already
|
|
144
|
+
# on changes nothing. Sending a different one ends the current cycle at
|
|
145
|
+
# that moment and prorates what accrued. +period_start+ backdates the cycle;
|
|
146
|
+
# +clear_plan+ takes the customer off plans entirely.
|
|
147
|
+
#
|
|
148
|
+
# Unlike {#track}, this waits and reports failure. track has a safe default
|
|
149
|
+
# - retry later - and "I could not record what this customer pays" has
|
|
150
|
+
# none, because a wrong plan is a wrong margin. It still never raises:
|
|
151
|
+
# check +result.ok?+, and +on_error+ is called too.
|
|
152
|
+
#
|
|
153
|
+
# @return [IdentifyResult]
|
|
154
|
+
def identify(customer_id:, plan: nil, clear_plan: false, period_start: nil,
|
|
155
|
+
name: nil, email: nil, metadata: nil)
|
|
156
|
+
body = {
|
|
157
|
+
"customerId" => customer_id,
|
|
158
|
+
"plan" => plan,
|
|
159
|
+
"clearPlan" => (true if clear_plan),
|
|
160
|
+
"periodStart" => period_start&.utc&.iso8601(6),
|
|
161
|
+
"name" => name,
|
|
162
|
+
"email" => email,
|
|
163
|
+
"metadata" => metadata
|
|
164
|
+
}.compact
|
|
165
|
+
|
|
166
|
+
response = post("/v1/identify", body, 5.0)
|
|
167
|
+
unless (200..299).cover?(response.code.to_i)
|
|
168
|
+
error = RuntimeError.new("identify: HTTP #{response.code}")
|
|
169
|
+
report(error, "identify")
|
|
170
|
+
return IdentifyResult.new(ok: false, error: error.message)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
parsed = JSON.parse(response.body)
|
|
174
|
+
IdentifyResult.new(
|
|
175
|
+
ok: true,
|
|
176
|
+
customer_id: parsed["customerId"],
|
|
177
|
+
plan: parsed["plan"],
|
|
178
|
+
period_start: parsed["periodStart"],
|
|
179
|
+
period_end: parsed["periodEnd"]
|
|
180
|
+
)
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
report(e, "identify")
|
|
183
|
+
IdentifyResult.new(ok: false, error: e.message)
|
|
184
|
+
end
|
|
185
|
+
|
|
131
186
|
# {#track} for jobs and scripts that must not exit early.
|
|
132
187
|
# @return [void]
|
|
133
188
|
def track_and_wait(**params)
|
|
@@ -153,7 +208,8 @@ module MarginFuse
|
|
|
153
208
|
#
|
|
154
209
|
# Yields the decision to the block, which must return a hash with +:usage+
|
|
155
210
|
# and optionally +:result+, +:cost_usd+ and +:outcome+. Use
|
|
156
|
-
# +decision.model+: a downgrade verdict changes
|
|
211
|
+
# +decision.model+ and +decision.provider+: a downgrade verdict changes
|
|
212
|
+
# them, and it can send you to another vendor.
|
|
157
213
|
#
|
|
158
214
|
# It yields rather than returning a decision for you to act on, because
|
|
159
215
|
# enforcement must not depend on the caller remembering to check anything.
|
|
@@ -164,9 +220,9 @@ module MarginFuse
|
|
|
164
220
|
# provider may still have charged for it.
|
|
165
221
|
#
|
|
166
222
|
# @return [GuardOutcome]
|
|
167
|
-
def guard(customer_id:, provider:, model:, feature: nil, expected_usage: nil)
|
|
223
|
+
def guard(customer_id:, provider:, model:, plan: nil, feature: nil, expected_usage: nil)
|
|
168
224
|
decision = decide(customer_id: customer_id, provider: provider, model: model,
|
|
169
|
-
feature: feature, expected_usage: expected_usage)
|
|
225
|
+
plan: plan, feature: feature, expected_usage: expected_usage)
|
|
170
226
|
|
|
171
227
|
# Enforcement depends on the ACTION alone. A missing id costs an
|
|
172
228
|
# acknowledgment; it must never turn a block into a provider call.
|
|
@@ -179,27 +235,34 @@ module MarginFuse
|
|
|
179
235
|
return GuardOutcome.new(kind: :topup_required, decision: decision)
|
|
180
236
|
end
|
|
181
237
|
|
|
238
|
+
# A downgrade can cross vendors, so the vendor that ran the call comes
|
|
239
|
+
# from the decision exactly as the model does. Reporting the caller's
|
|
240
|
+
# would price the call from the wrong catalog and credit the saving to the
|
|
241
|
+
# wrong vendor. The decision defaults both to what was asked for, so
|
|
242
|
+
# anything but a downgrade reports what it always did.
|
|
182
243
|
model_used = decision.action == :downgrade ? decision.model : model
|
|
244
|
+
provider_used = decision.action == :downgrade ? decision.provider : provider
|
|
245
|
+
# A downgrade whose provider call then fails is still a downgrade: the
|
|
246
|
+
# cheaper model is the one that ran, so both paths acknowledge the same.
|
|
247
|
+
acknowledgment =
|
|
248
|
+
decision.action == :downgrade ? :used_downgrade_model : :proceeded_as_requested
|
|
183
249
|
|
|
184
250
|
begin
|
|
185
251
|
call = yield(decision)
|
|
186
252
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
187
|
-
track(customer_id: customer_id, feature: feature, provider:
|
|
253
|
+
track(customer_id: customer_id, plan: plan, feature: feature, provider: provider_used,
|
|
188
254
|
model: model_used, requested_model: model, usage: {},
|
|
189
255
|
outcome: :provider_error, decision_id: decision.id)
|
|
190
|
-
acknowledge(decision.id,
|
|
256
|
+
acknowledge(decision.id, acknowledgment) if decision.id
|
|
191
257
|
raise e
|
|
192
258
|
end
|
|
193
259
|
|
|
194
260
|
call ||= {}
|
|
195
|
-
track(customer_id: customer_id, feature: feature, provider:
|
|
261
|
+
track(customer_id: customer_id, plan: plan, feature: feature, provider: provider_used,
|
|
196
262
|
model: model_used, requested_model: model, usage: call[:usage],
|
|
197
263
|
cost_usd: call[:cost_usd], outcome: call[:outcome] || :success,
|
|
198
264
|
decision_id: decision.id)
|
|
199
|
-
if decision.id
|
|
200
|
-
acknowledge(decision.id,
|
|
201
|
-
decision.action == :downgrade ? :used_downgrade_model : :proceeded_as_requested)
|
|
202
|
-
end
|
|
265
|
+
acknowledge(decision.id, acknowledgment) if decision.id
|
|
203
266
|
|
|
204
267
|
GuardOutcome.new(kind: :completed, decision: decision, result: call[:result])
|
|
205
268
|
end
|
data/lib/marginfuse/types.rb
CHANGED
|
@@ -29,6 +29,23 @@ module MarginFuse
|
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# What {Client#identify} recorded, or why it could not.
|
|
33
|
+
#
|
|
34
|
+
# +ok+ is the only field to branch on. When it is false the call changed
|
|
35
|
+
# nothing and +error+ says what happened; the SDK still did not raise.
|
|
36
|
+
#
|
|
37
|
+
# Unlike {Client#track}, identify reports its failures. track has a safe
|
|
38
|
+
# default - retry later - and "I could not record what this customer pays"
|
|
39
|
+
# has none: a wrong plan is a wrong margin.
|
|
40
|
+
IdentifyResult = Struct.new(
|
|
41
|
+
:ok, :customer_id, :plan, :period_start, :period_end, :error,
|
|
42
|
+
keyword_init: true
|
|
43
|
+
) do
|
|
44
|
+
def ok?
|
|
45
|
+
!!ok
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
32
49
|
# The result of the whole guard loop. +kind+ is :completed, :blocked or
|
|
33
50
|
# :topup_required. +result+ is your block's own return value.
|
|
34
51
|
GuardOutcome = Struct.new(:kind, :decision, :result, keyword_init: true) do
|
data/lib/marginfuse/version.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MarginFuse
|
|
4
|
-
VERSION = "0.
|
|
4
|
+
VERSION = "0.3.0"
|
|
5
5
|
|
|
6
6
|
# The version of the shared SDK contract this build was verified against.
|
|
7
7
|
#
|
|
@@ -12,5 +12,5 @@ module MarginFuse
|
|
|
12
12
|
# same vectors.
|
|
13
13
|
#
|
|
14
14
|
# See github.com/marginfuse/sdk-contract
|
|
15
|
-
CONTRACT_VERSION =
|
|
15
|
+
CONTRACT_VERSION = 2
|
|
16
16
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: marginfuse
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Pemira Labs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |-
|
|
14
14
|
MarginFuse server-side SDK. Connect revenue to per-request AI cost, see gross
|